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/35] [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/35] 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/35] [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/35] [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/35] [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/35] [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/35] 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/35] [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/35] 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/35] [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, From 0691583b5059bc473065720a9930572b95f886f8 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 23 Oct 2025 11:16:17 -0400 Subject: [PATCH 19/35] [PM-23133] refactor members component (#16703) * WIP: added new services, refactor members to use billing service and member action service * replace dialog logic and user logic with service implementations * WIP * wip add tests * add tests, continue refactoring * clean up * move BillingConstraintService to billing ownership * fix import * fix seat count not updating if feature flag is disabled * refactor billingMetadata, clean up --- .../common/base-members.component.ts | 57 +- .../members/members.component.html | 9 +- .../members/members.component.ts | 738 ++++-------------- .../organizations/members/members.module.ts | 12 + .../organizations/members/services/index.ts | 5 + .../member-actions.service.spec.ts | 463 +++++++++++ .../member-actions/member-actions.service.ts | 210 +++++ .../member-dialog-manager.service.spec.ts | 640 +++++++++++++++ .../member-dialog-manager.service.ts | 322 ++++++++ .../organization-members.service.spec.ts | 362 +++++++++ .../organization-members.service.ts | 76 ++ .../billing-constraint.service.spec.ts | 461 +++++++++++ .../billing-constraint.service.ts | 192 +++++ .../providers/manage/members.component.ts | 38 +- .../organization-metadata.service.spec.ts | 7 +- .../organization-metadata.service.ts | 73 +- 16 files changed, 2999 insertions(+), 666 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/services/index.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts create mode 100644 apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts create mode 100644 apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts diff --git a/apps/web/src/app/admin-console/common/base-members.component.ts b/apps/web/src/app/admin-console/common/base-members.component.ts index 21c52949254..5ecf4269a1a 100644 --- a/apps/web/src/app/admin-console/common/base-members.component.ts +++ b/apps/web/src/app/admin-console/common/base-members.component.ts @@ -24,6 +24,7 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; import { UserConfirmComponent } from "../organizations/manage/user-confirm.component"; +import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service"; import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source"; @@ -75,7 +76,7 @@ export abstract class BaseMembersComponent { /** * The currently executing promise - used to avoid multiple user actions executing at once. */ - actionPromise?: Promise; + actionPromise?: Promise; protected searchControl = new FormControl("", { nonNullable: true }); protected statusToggle = new BehaviorSubject(undefined); @@ -101,13 +102,13 @@ export abstract class BaseMembersComponent { abstract edit(user: UserView, organization?: Organization): void; abstract getUsers(organization?: Organization): Promise | UserView[]>; - abstract removeUser(id: string, organization?: Organization): Promise; - abstract reinviteUser(id: string, organization?: Organization): Promise; + abstract removeUser(id: string, organization?: Organization): Promise; + abstract reinviteUser(id: string, organization?: Organization): Promise; abstract confirmUser( user: UserView, publicKey: Uint8Array, organization?: Organization, - ): Promise; + ): Promise; abstract invite(organization?: Organization): void; async load(organization?: Organization) { @@ -140,12 +141,16 @@ export abstract class BaseMembersComponent { this.actionPromise = this.removeUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), - }); - this.dataSource.removeUser(user); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), + }); + this.dataSource.removeUser(user); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -159,11 +164,15 @@ export abstract class BaseMembersComponent { this.actionPromise = this.reinviteUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), - }); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -174,14 +183,18 @@ export abstract class BaseMembersComponent { const confirmUser = async (publicKey: Uint8Array) => { try { this.actionPromise = this.confirmUser(user, publicKey, organization); - await this.actionPromise; - user.status = this.userStatusType.Confirmed; - this.dataSource.replaceUser(user); + const result = await this.actionPromise; + if (result.success) { + user.status = this.userStatusType.Confirmed; + this.dataSource.replaceUser(user); - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), - }); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); throw e; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 282291eb60e..9401a88ab76 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -2,7 +2,7 @@ @if (organization) { @@ -339,7 +339,10 @@ > {{ "userUsingTwoStep" | i18n }}
- + @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; + {{ "recoverAccount" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 3841f6d5b4b..324452499dc 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -1,14 +1,12 @@ import { Component, computed, Signal } from "@angular/core"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { - BehaviorSubject, combineLatest, concatMap, filter, firstValueFrom, from, - lastValueFrom, map, merge, Observable, @@ -17,24 +15,12 @@ import { take, } from "rxjs"; -import { - OrganizationUserApiService, - OrganizationUserConfirmRequest, - OrganizationUserUserDetailsResponse, - CollectionService, - CollectionData, - Collection, - CollectionDetailsResponse, -} from "@bitwarden/admin-console/common"; +import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; -import { PolicyApiServiceAbstraction as PolicyApiService } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.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 { OrganizationUserStatusType, @@ -43,53 +29,32 @@ import { } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; +import { getById } from "@bitwarden/common/platform/misc"; +import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { - ChangePlanDialogResultType, - openChangePlanDialog, -} from "../../../billing/organizations/change-plan-dialog.component"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; -import { GroupApiService } from "../core"; import { OrganizationUserView } from "../core/views/organization-user.view"; -import { openEntityEventsDialog } from "../manage/entity-events.component"; -import { - AccountRecoveryDialogComponent, - AccountRecoveryDialogResultType, -} from "./components/account-recovery/account-recovery-dialog.component"; -import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; -import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component"; -import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; -import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; -import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; -import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; -import { - MemberDialogResult, - MemberDialogTab, - openUserAddEditDialog, -} from "./components/member-dialog"; -import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator"; +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; +import { MemberDialogManagerService, OrganizationMembersService } from "./services"; import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; -import { OrganizationUserService } from "./services/organization-user/organization-user.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; class MembersTableDataSource extends PeopleTableDataSource { protected statusType = OrganizationUserStatusType; @@ -107,7 +72,10 @@ export class MembersComponent extends BaseMembersComponent readonly organization: Signal; status: OrganizationUserStatusType | undefined; - orgResetPasswordPolicyEnabled = false; + + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + resetPasswordPolicyEnabled$: Observable; protected readonly canUseSecretsManager: Signal = computed( () => this.organization()?.useSecretsManager ?? false, @@ -115,43 +83,34 @@ export class MembersComponent extends BaseMembersComponent protected readonly showUserManagementControls: Signal = computed( () => this.organization()?.canManageUsers ?? false, ); - private refreshBillingMetadata$: BehaviorSubject = new BehaviorSubject(null); protected billingMetadata$: Observable; // Fixed sizes used for cdkVirtualScroll protected rowHeight = 66; protected rowHeightClass = `tw-h-[66px]`; - private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); - constructor( apiService: ApiService, i18nService: I18nService, organizationManagementPreferencesService: OrganizationManagementPreferencesService, keyService: KeyService, - private encryptService: EncryptService, validationService: ValidationService, logService: LogService, userNamePipe: UserNamePipe, dialogService: DialogService, toastService: ToastService, - private policyService: PolicyService, - private policyApiService: PolicyApiService, private route: ActivatedRoute, - private syncService: SyncService, + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationWarningsService: OrganizationWarningsService, + private memberActionsService: MemberActionsService, + private memberDialogManager: MemberDialogManagerService, + protected billingConstraint: BillingConstraintService, + protected memberService: OrganizationMembersService, private organizationService: OrganizationService, private accountService: AccountService, - private organizationApiService: OrganizationApiServiceAbstraction, - private organizationUserApiService: OrganizationUserApiService, - private router: Router, - private groupService: GroupApiService, - private collectionService: CollectionService, - private billingApiService: BillingApiServiceAbstraction, + private policyService: PolicyService, + private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, - protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, - private configService: ConfigService, - private organizationUserService: OrganizationUserService, - private organizationWarningsService: OrganizationWarningsService, ) { super( apiService, @@ -169,14 +128,12 @@ export class MembersComponent extends BaseMembersComponent concatMap((params) => this.userId$.pipe( switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(params.organizationId)), + this.organizationService.organizations$(userId).pipe(getById(params.organizationId)), ), + filter((organization): organization is Organization => organization != null), + shareReplay({ refCount: true, bufferSize: 1 }), ), ), - filter((organization): organization is Organization => organization != null), - shareReplay({ refCount: true, bufferSize: 1 }), ); this.organization = toSignal(organization$); @@ -191,53 +148,26 @@ export class MembersComponent extends BaseMembersComponent ), ); - combineLatest([this.route.queryParams, policies$, organization$]) - .pipe( - concatMap(async ([qParams, policies, organization]) => { - // Backfill pub/priv key if necessary - if (organization.canManageUsersPassword && !organization.hasPublicAndPrivateKeys) { - const orgShareKey = await firstValueFrom( - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - map((orgKeys) => { - if (orgKeys == null || orgKeys[organization.id] == null) { - throw new Error("Organization keys not found for provided User."); - } - return orgKeys[organization.id]; - }), - ), - ); - - const [orgPublicKey, encryptedOrgPrivateKey] = - await this.keyService.makeKeyPair(orgShareKey); - if (encryptedOrgPrivateKey.encryptedString == null) { - throw new Error("Encrypted private key is null."); - } - const request = new OrganizationKeysRequest( - orgPublicKey, - encryptedOrgPrivateKey.encryptedString, - ); - const response = await this.organizationApiService.updateKeys(organization.id, request); - if (response != null) { - await this.syncService.fullSync(true); // Replace organizations with new data - } else { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - } - - const resetPasswordPolicy = policies + this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe( + map( + ([organization, policies]) => + policies .filter((policy) => policy.type === PolicyType.ResetPassword) - .find((p) => p.organizationId === organization.id); - this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled ?? false; + .find((p) => p.organizationId === organization.id)?.enabled ?? false, + ), + ); - await this.load(organization); + combineLatest([this.route.queryParams, organization$]) + .pipe( + concatMap(async ([qParams, organization]) => { + await this.load(organization!); this.searchControl.setValue(qParams.search); if (qParams.viewEvents != null) { const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - this.openEventsDialog(user[0], organization); + this.openEventsDialog(user[0], organization!); } } }), @@ -257,11 +187,10 @@ export class MembersComponent extends BaseMembersComponent ) .subscribe(); - this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( - switchMap(([_, organization]) => + this.billingMetadata$ = organization$.pipe( + switchMap((organization) => this.organizationMetadataService.getOrganizationMetadata$(organization.id), ), - takeUntilDestroyed(), shareReplay({ bufferSize: 1, refCount: false }), ); @@ -271,136 +200,35 @@ export class MembersComponent extends BaseMembersComponent } override async load(organization: Organization) { - this.refreshBillingMetadata$.next(null); await super.load(organization); } async getUsers(organization: Organization): Promise { - let groupsPromise: Promise> | undefined; - let collectionsPromise: Promise> | undefined; - - // We don't need both groups and collections for the table, so only load one - const userPromise = this.organizationUserApiService.getAllUsers(organization.id, { - includeGroups: organization.useGroups, - includeCollections: !organization.useGroups, - }); - - // Depending on which column is displayed, we need to load the group/collection names - if (organization.useGroups) { - groupsPromise = this.getGroupNameMap(organization); - } else { - collectionsPromise = this.getCollectionNameMap(organization); - } - - const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([ - userPromise, - groupsPromise, - collectionsPromise, - ]); - - return ( - usersResponse.data?.map((r) => { - const userView = OrganizationUserView.fromResponse(r); - - userView.groupNames = userView.groups - .map((g) => groupNamesMap?.get(g)) - .filter((name): name is string => name != null) - .sort(this.i18nService.collator?.compare); - userView.collectionNames = userView.collections - .map((c) => collectionNamesMap?.get(c.id)) - .filter((name): name is string => name != null) - .sort(this.i18nService.collator?.compare); - - return userView; - }) ?? [] - ); + return await this.memberService.loadUsers(organization); } - async getGroupNameMap(organization: Organization): Promise> { - const groups = await this.groupService.getAll(organization.id); - const groupNameMap = new Map(); - groups.forEach((g) => groupNameMap.set(g.id, g.name)); - return groupNameMap; + async removeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.removeUser(organization, id); } - /** - * Retrieve a map of all collection IDs <-> names for the organization. - */ - async getCollectionNameMap(organization: Organization) { - const response = from(this.apiService.getCollections(organization.id)).pipe( - map((res) => - res.data.map((r) => - Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), - ), - ), - ); - - const decryptedCollections$ = combineLatest([ - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - filter((orgKeys) => orgKeys != null), - ), - response, - ]).pipe( - switchMap(([orgKeys, collections]) => - this.collectionService.decryptMany$(collections, orgKeys), - ), - map((collections) => { - const collectionMap = new Map(); - collections.forEach((c) => collectionMap.set(c.id, c.name)); - return collectionMap; - }), - ); - - return await firstValueFrom(decryptedCollections$); + async revokeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.revokeUser(organization, id); } - removeUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.removeOrganizationUser(organization.id, id); + async restoreUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.restoreUser(organization, id); } - revokeUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.revokeOrganizationUser(organization.id, id); - } - - restoreUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.restoreOrganizationUser(organization.id, id); - } - - reinviteUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.postOrganizationUserReinvite(organization.id, id); + async reinviteUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.reinviteUser(organization, id); } async confirmUser( user: OrganizationUserView, publicKey: Uint8Array, organization: Organization, - ): Promise { - if ( - await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) - ) { - await firstValueFrom(this.organizationUserService.confirmUser(organization, user, publicKey)); - } else { - const request = await firstValueFrom( - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - filter((orgKeys) => orgKeys != null), - map((orgKeys) => orgKeys[organization.id]), - switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), - map((encKey) => { - const req = new OrganizationUserConfirmRequest(); - req.key = encKey.encryptedString; - return req; - }), - ), - ); - - await this.organizationUserApiService.postOrganizationUserConfirm( - organization.id, - user.id, - request, - ); - } + ): Promise { + return await this.memberActionsService.confirmUser(user, publicKey, organization); } async revoke(user: OrganizationUserView, organization: Organization) { @@ -412,12 +240,16 @@ export class MembersComponent extends BaseMembersComponent this.actionPromise = this.revokeUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -427,198 +259,68 @@ export class MembersComponent extends BaseMembersComponent async restore(user: OrganizationUserView, organization: Organization) { this.actionPromise = this.restoreUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } this.actionPromise = undefined; } - allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean { - let callingUserHasPermission = false; - - switch (organization.type) { - case OrganizationUserType.Owner: - callingUserHasPermission = true; - break; - case OrganizationUserType.Admin: - callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner; - break; - case OrganizationUserType.Custom: - callingUserHasPermission = - orgUser.type !== OrganizationUserType.Owner && - orgUser.type !== OrganizationUserType.Admin; - break; - } - - return ( - organization.canManageUsersPassword && - callingUserHasPermission && - organization.useResetPassword && - organization.hasPublicAndPrivateKeys && - orgUser.resetPasswordEnrolled && - this.orgResetPasswordPolicyEnabled && - orgUser.status === OrganizationUserStatusType.Confirmed + allowResetPassword( + orgUser: OrganizationUserView, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return this.memberActionsService.allowResetPassword( + orgUser, + organization, + orgResetPasswordPolicyEnabled, ); } showEnrolledStatus( orgUser: OrganizationUserUserDetailsResponse, organization: Organization, + orgResetPasswordPolicyEnabled: boolean, ): boolean { return ( organization.useResetPassword && orgUser.resetPasswordEnrolled && - this.orgResetPasswordPolicyEnabled - ); - } - - private getManageBillingText(organization: Organization): string { - return organization.canEditSubscription ? "ManageBilling" : "NoManageBilling"; - } - - private getProductKey(organization: Organization): string { - let product = ""; - switch (organization.productTierType) { - case ProductTierType.Free: - product = "freeOrg"; - break; - case ProductTierType.TeamsStarter: - product = "teamsStarterPlan"; - break; - case ProductTierType.Families: - product = "familiesPlan"; - break; - default: - throw new Error(`Unsupported product type: ${organization.productTierType}`); - } - return `${product}InvLimitReached${this.getManageBillingText(organization)}`; - } - - private getDialogContent(organization: Organization): string { - return this.i18nService.t(this.getProductKey(organization), organization.seats); - } - - private getAcceptButtonText(organization: Organization): string { - if (!organization.canEditSubscription) { - return this.i18nService.t("ok"); - } - - const productType = organization.productTierType; - - if (isNotSelfUpgradable(productType)) { - throw new Error(`Unsupported product type: ${productType}`); - } - - return this.i18nService.t("upgrade"); - } - - private async handleDialogClose( - result: boolean | undefined, - organization: Organization, - ): Promise { - if (!result || !organization.canEditSubscription) { - return; - } - - const productType = organization.productTierType; - - if (isNotSelfUpgradable(productType)) { - throw new Error(`Unsupported product type: ${organization.productTierType}`); - } - - await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], { - queryParams: { upgrade: true }, - }); - } - - private async showSeatLimitReachedDialog(organization: Organization): Promise { - const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("upgradeOrganization"), - content: this.getDialogContent(organization), - type: "primary", - acceptButtonText: this.getAcceptButtonText(organization), - }; - - if (!organization.canEditSubscription) { - orgUpgradeSimpleDialogOpts.cancelButtonText = null; - } - - const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); - await lastValueFrom( - simpleDialog.closed.pipe(map((closed) => this.handleDialogClose(closed, organization))), + orgResetPasswordPolicyEnabled ); } private async handleInviteDialog(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const dialog = openUserAddEditDialog(this.dialogService, { - data: { - kind: "Add", - organizationId: organization.id, - allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], - occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0, - isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, - }, - }); + const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; - const result = await lastValueFrom(dialog.closed); + const result = await this.memberDialogManager.openInviteDialog( + organization, + billingMetadata, + allUserEmails, + ); if (result === MemberDialogResult.Saved) { await this.load(organization); } } - private async handleSeatLimitForFixedTiers(organization: Organization) { - if (!organization.canEditSubscription) { - await this.showSeatLimitReachedDialog(organization); - return; - } - - const reference = openChangePlanDialog(this.dialogService, { - data: { - organizationId: organization.id, - productTierType: organization.productTierType, - }, - }); - - const result = await lastValueFrom(reference.closed); - - if (result === ChangePlanDialogResultType.Submitted) { - await this.load(organization); - } - } - async invite(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - if ( - organization.hasReseller && - organization.seats === billingMetadata?.organizationOccupiedSeats - ) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("seatLimitReached"), - message: this.i18nService.t("contactYourProvider"), - }); - - return; + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { + await this.handleInviteDialog(organization); + this.organizationMetadataService.refreshMetadataCache(); } - - if ( - billingMetadata?.organizationOccupiedSeats === organization.seats && - isFixedSeatPlan(organization.productTierType) - ) { - await this.handleSeatLimitForFixedTiers(organization); - - return; - } - - await this.handleInviteDialog(organization); } async edit( @@ -627,20 +329,14 @@ export class MembersComponent extends BaseMembersComponent initialTab: MemberDialogTab = MemberDialogTab.Role, ) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const dialog = openUserAddEditDialog(this.dialogService, { - data: { - kind: "Edit", - name: this.userNamePipe.transform(user), - organizationId: organization.id, - organizationUserId: user.id, - usesKeyConnector: user.usesKeyConnector, - isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, - initialTab: initialTab, - managedByOrganization: user.managedByOrganization, - }, - }); - const result = await lastValueFrom(dialog.closed); + const result = await this.memberDialogManager.openEditDialog( + user, + organization, + billingMetadata, + initialTab, + ); + switch (result) { case MemberDialogResult.Deleted: this.dataSource.removeUser(user); @@ -658,43 +354,23 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkRemoveDialog( + organization, + this.dataSource.getCheckedUsers(), + ); + this.organizationMetadataService.refreshMetadataCache(); await this.load(organization); } async bulkDelete(organization: Organization) { - const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), - ); - - if ( - !warningAcknowledged && - organization.canManageUsers && - organization.productTierType === ProductTierType.Enterprise - ) { - const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); - if (!acknowledged) { - return; - } - } - if (this.actionPromise != null) { return; } - const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkDeleteDialog( + organization, + this.dataSource.getCheckedUsers(), + ); await this.load(organization); } @@ -711,13 +387,11 @@ export class MembersComponent extends BaseMembersComponent return; } - const ref = BulkRestoreRevokeComponent.open(this.dialogService, { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - isRevoking: isRevoking, - }); - - await firstValueFrom(ref.closed); + await this.memberDialogManager.openBulkRestoreRevokeDialog( + organization, + this.dataSource.getCheckedUsers(), + isRevoking, + ); await this.load(organization); } @@ -739,20 +413,22 @@ export class MembersComponent extends BaseMembersComponent } try { - const response = this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, + const result = await this.memberActionsService.bulkReinvite( + organization, filteredUsers.map((user) => user.id), ); + + if (!result.successful) { + throw new Error(); + } + // Bulk Status component open - const dialogRef = BulkStatusComponent.open(this.dialogService, { - data: { - users: users, - filteredUsers: filteredUsers, - request: response, - successfulMessage: this.i18nService.t("bulkReinviteMessage"), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); } catch (e) { this.validationService.showError(e); } @@ -764,49 +440,24 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { - data: { - organization: organization, - users: this.dataSource.getCheckedUsers(), - }, - }); - - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkConfirmDialog( + organization, + this.dataSource.getCheckedUsers(), + ); await this.load(organization); } async bulkEnableSM(organization: Organization) { - const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager); + const users = this.dataSource.getCheckedUsers(); - if (users.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noSelectedUsersApplicable"), - }); - return; - } + await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); - const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, { - orgId: organization.id, - users, - }); - - await lastValueFrom(dialogRef.closed); this.dataSource.uncheckAllUsers(); await this.load(organization); } openEventsDialog(user: OrganizationUserView, organization: Organization) { - openEntityEventsDialog(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - organizationId: organization.id, - entityId: user.id, - showUser: false, - entity: "user", - }, - }); + this.memberDialogManager.openEventsDialog(user, organization); } async resetPassword(user: OrganizationUserView, organization: Organization) { @@ -821,16 +472,7 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - email: user.email, - organizationId: organization.id as OrganizationId, - organizationUserId: user.id, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); + const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization); if (result === AccountRecoveryDialogResultType.Ok) { await this.load(organization); } @@ -839,91 +481,29 @@ export class MembersComponent extends BaseMembersComponent } protected async removeUserConfirmationDialog(user: OrganizationUserView) { - const content = user.usesKeyConnector - ? "removeUserConfirmationKeyConnector" - : "removeOrgUserConfirmation"; - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { - key: "removeUserIdAccess", - placeholders: [this.userNamePipe.transform(user)], - }, - content: { key: content }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) { - return await this.noMasterPasswordConfirmationDialog(user); - } - - return true; + return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); } protected async revokeUserConfirmationDialog(user: OrganizationUserView) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] }, - content: this.i18nService.t("revokeUserConfirmation"), - acceptButtonText: { key: "revokeAccess" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) { - return await this.noMasterPasswordConfirmationDialog(user); - } - - return true; + return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); } async deleteUser(user: OrganizationUserView, organization: Organization) { - const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), + const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( + user, + organization, ); - if ( - !warningAcknowledged && - organization.canManageUsers && - organization.productTierType === ProductTierType.Enterprise - ) { - const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); - if (!acknowledged) { - return false; - } - } - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { - key: "deleteOrganizationUser", - placeholders: [this.userNamePipe.transform(user)], - }, - content: { - key: "deleteOrganizationUserWarningDesc", - placeholders: [this.userNamePipe.transform(user)], - }, - type: "warning", - acceptButtonText: { key: "delete" }, - cancelButtonText: { key: "cancel" }, - }); - if (!confirmed) { return false; } - await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id); - - this.actionPromise = this.organizationUserApiService.deleteOrganizationUser( - organization.id, - user.id, - ); + this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); try { - await this.actionPromise; + const result = await this.actionPromise; + if (!result.success) { + throw new Error(result.error); + } this.toastService.showToast({ variant: "success", message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), @@ -935,19 +515,6 @@ export class MembersComponent extends BaseMembersComponent this.actionPromise = undefined; } - private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) { - return this.dialogService.openSimpleDialog({ - title: { - key: "removeOrgUserNoMasterPasswordTitle", - }, - content: { - key: "removeOrgUserNoMasterPasswordDesc", - placeholders: [this.userNamePipe.transform(user)], - }, - type: "warning", - }); - } - get showBulkRestoreUsers(): boolean { return this.dataSource .getCheckedUsers() @@ -975,13 +542,4 @@ export class MembersComponent extends BaseMembersComponent .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } - - async navigateToPaymentMethod(organization: Organization) { - await this.router.navigate( - ["organizations", `${organization.id}`, "billing", "payment-details"], - { - state: { launchPaymentModalAutomatically: true }, - }, - ); - } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index e5bc5f29a3b..3b233932ed3 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,6 +4,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -18,6 +19,11 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; import { MembersRoutingModule } from "./members-routing.module"; import { MembersComponent } from "./members.component"; +import { + OrganizationMembersService, + MemberActionsService, + MemberDialogManagerService, +} from "./services"; @NgModule({ imports: [ @@ -40,5 +46,11 @@ import { MembersComponent } from "./members.component"; MembersComponent, BulkDeleteDialogComponent, ], + providers: [ + OrganizationMembersService, + MemberActionsService, + BillingConstraintService, + MemberDialogManagerService, + ], }) export class MembersModule {} diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts new file mode 100644 index 00000000000..2ac2d31cd69 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -0,0 +1,5 @@ +export { OrganizationMembersService } from "./organization-members-service/organization-members.service"; +export { MemberActionsService } from "./member-actions/member-actions.service"; +export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; +export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; +export { OrganizationUserService } from "./organization-user/organization-user.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts new file mode 100644 index 00000000000..6fd7de7b292 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -0,0 +1,463 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + OrganizationUserApiService, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { + OrganizationUserType, + OrganizationUserStatusType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { OrganizationUserService } from "../organization-user/organization-user.service"; + +import { MemberActionsService } from "./member-actions.service"; + +describe("MemberActionsService", () => { + let service: MemberActionsService; + let organizationUserApiService: MockProxy; + let organizationUserService: MockProxy; + let keyService: MockProxy; + let encryptService: MockProxy; + let configService: MockProxy; + let accountService: FakeAccountService; + let billingConstraintService: MockProxy; + + const userId = newGuid() as UserId; + const organizationId = newGuid() as OrganizationId; + const userIdToManage = newGuid(); + + let mockOrganization: Organization; + let mockOrgUser: OrganizationUserView; + + beforeEach(() => { + organizationUserApiService = mock(); + organizationUserService = mock(); + keyService = mock(); + encryptService = mock(); + configService = mock(); + accountService = mockAccountServiceWith(userId); + billingConstraintService = mock(); + + mockOrganization = { + id: organizationId, + type: OrganizationUserType.Owner, + canManageUsersPassword: true, + hasPublicAndPrivateKeys: true, + useResetPassword: true, + } as Organization; + + mockOrgUser = { + id: userIdToManage, + userId: userIdToManage, + type: OrganizationUserType.User, + status: OrganizationUserStatusType.Confirmed, + resetPasswordEnrolled: true, + } as OrganizationUserView; + + service = new MemberActionsService( + organizationUserApiService, + organizationUserService, + keyService, + encryptService, + configService, + accountService, + billingConstraintService, + ); + }); + + describe("inviteUser", () => { + it("should successfully invite a user", async () => { + organizationUserApiService.postOrganizationUserInvite.mockResolvedValue(undefined); + + const result = await service.inviteUser( + mockOrganization, + "test@example.com", + OrganizationUserType.User, + {}, + [], + [], + ); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.postOrganizationUserInvite).toHaveBeenCalledWith( + organizationId, + { + emails: ["test@example.com"], + type: OrganizationUserType.User, + accessSecretsManager: false, + collections: [], + groups: [], + permissions: {}, + }, + ); + }); + + it("should handle invite errors", async () => { + const errorMessage = "Invitation failed"; + organizationUserApiService.postOrganizationUserInvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.inviteUser( + mockOrganization, + "test@example.com", + OrganizationUserType.User, + ); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("removeUser", () => { + it("should successfully remove a user", async () => { + organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined); + + const result = await service.removeUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.removeOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + }); + + it("should handle remove errors", async () => { + const errorMessage = "Remove failed"; + organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error(errorMessage)); + + const result = await service.removeUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("revokeUser", () => { + it("should successfully revoke a user", async () => { + organizationUserApiService.revokeOrganizationUser.mockResolvedValue(undefined); + + const result = await service.revokeUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.revokeOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + }); + + it("should handle revoke errors", async () => { + const errorMessage = "Revoke failed"; + organizationUserApiService.revokeOrganizationUser.mockRejectedValue(new Error(errorMessage)); + + const result = await service.revokeUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("restoreUser", () => { + it("should successfully restore a user", async () => { + organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + }); + + it("should handle restore errors", async () => { + const errorMessage = "Restore failed"; + organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage)); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("deleteUser", () => { + it("should successfully delete a user", async () => { + organizationUserApiService.deleteOrganizationUser.mockResolvedValue(undefined); + + const result = await service.deleteUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.deleteOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + }); + + it("should handle delete errors", async () => { + const errorMessage = "Delete failed"; + organizationUserApiService.deleteOrganizationUser.mockRejectedValue(new Error(errorMessage)); + + const result = await service.deleteUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("reinviteUser", () => { + it("should successfully reinvite a user", async () => { + organizationUserApiService.postOrganizationUserReinvite.mockResolvedValue(undefined); + + const result = await service.reinviteUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.postOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + }); + + it("should handle reinvite errors", async () => { + const errorMessage = "Reinvite failed"; + organizationUserApiService.postOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.reinviteUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); + }); + + describe("confirmUser", () => { + const publicKey = new Uint8Array([1, 2, 3, 4, 5]); + + it("should confirm user using new flow when feature flag is enabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + organizationUserService.confirmUser.mockReturnValue(of(undefined)); + + const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); + + expect(result).toEqual({ success: true }); + expect(organizationUserService.confirmUser).toHaveBeenCalledWith( + mockOrganization, + mockOrgUser, + publicKey, + ); + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should confirm user using exising flow when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + const mockOrgKey = mock(); + const mockOrgKeys = { [organizationId]: mockOrgKey }; + keyService.orgKeys$.mockReturnValue(of(mockOrgKeys)); + + const mockEncryptedKey = new EncString("encrypted-key-data"); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey); + + organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); + + const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); + + expect(result).toEqual({ success: true }); + expect(keyService.orgKeys$).toHaveBeenCalledWith(userId); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey); + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + organizationId, + userIdToManage, + expect.objectContaining({ + key: "encrypted-key-data", + }), + ); + }); + + it("should handle missing organization keys", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + keyService.orgKeys$.mockReturnValue(of({})); + + const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); + + expect(result.success).toBe(false); + expect(result.error).toContain("Organization keys not found"); + }); + + it("should handle confirm errors", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + const errorMessage = "Confirm failed"; + organizationUserService.confirmUser.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); + + expect(result.success).toBe(false); + expect(result.error).toContain(errorMessage); + }); + }); + + describe("bulkReinvite", () => { + const userIds = [newGuid(), newGuid(), newGuid()]; + + it("should successfully reinvite multiple users", async () => { + const mockResponse = { + data: userIds.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + } as ListResponse; + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result).toEqual({ + successful: mockResponse, + failed: [], + }); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIds, + ); + }); + + it("should handle bulk reinvite errors", async () => { + const errorMessage = "Bulk reinvite failed"; + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(3); + expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); + }); + }); + + describe("allowResetPassword", () => { + const resetPasswordEnabled = true; + + it("should allow reset password for Owner over User", () => { + const result = service.allowResetPassword( + mockOrgUser, + mockOrganization, + resetPasswordEnabled, + ); + + expect(result).toBe(true); + }); + + it("should allow reset password for Admin over User", () => { + const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization; + + const result = service.allowResetPassword(mockOrgUser, adminOrg, resetPasswordEnabled); + + expect(result).toBe(true); + }); + + it("should not allow reset password for Admin over Owner", () => { + const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization; + const ownerUser = { + ...mockOrgUser, + type: OrganizationUserType.Owner, + } as OrganizationUserView; + + const result = service.allowResetPassword(ownerUser, adminOrg, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should allow reset password for Custom over User", () => { + const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization; + + const result = service.allowResetPassword(mockOrgUser, customOrg, resetPasswordEnabled); + + expect(result).toBe(true); + }); + + it("should not allow reset password for Custom over Admin", () => { + const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization; + const adminUser = { + ...mockOrgUser, + type: OrganizationUserType.Admin, + } as OrganizationUserView; + + const result = service.allowResetPassword(adminUser, customOrg, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password for Custom over Owner", () => { + const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization; + const ownerUser = { + ...mockOrgUser, + type: OrganizationUserType.Owner, + } as OrganizationUserView; + + const result = service.allowResetPassword(ownerUser, customOrg, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password when organization cannot manage users password", () => { + const org = { ...mockOrganization, canManageUsersPassword: false } as Organization; + + const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password when organization does not use reset password", () => { + const org = { ...mockOrganization, useResetPassword: false } as Organization; + + const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password when organization lacks public and private keys", () => { + const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization; + + const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password when user is not enrolled in reset password", () => { + const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView; + + const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled); + + expect(result).toBe(false); + }); + + it("should not allow reset password when reset password is disabled", () => { + const result = service.allowResetPassword(mockOrgUser, mockOrganization, false); + + expect(result).toBe(false); + }); + + it("should not allow reset password when user status is not confirmed", () => { + const user = { + ...mockOrgUser, + status: OrganizationUserStatusType.Invited, + } as OrganizationUserView; + + const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled); + + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts new file mode 100644 index 00000000000..3697aba94ff --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -0,0 +1,210 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, switchMap, map } from "rxjs"; + +import { + OrganizationUserApiService, + OrganizationUserBulkResponse, + OrganizationUserConfirmRequest, +} from "@bitwarden/admin-console/common"; +import { + OrganizationUserType, + OrganizationUserStatusType, +} from "@bitwarden/common/admin-console/enums"; +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 { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { KeyService } from "@bitwarden/key-management"; + +import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { OrganizationUserService } from "../organization-user/organization-user.service"; + +export interface MemberActionResult { + success: boolean; + error?: string; +} + +export interface BulkActionResult { + successful?: ListResponse; + failed: { id: string; error: string }[]; +} + +@Injectable() +export class MemberActionsService { + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + + constructor( + private organizationUserApiService: OrganizationUserApiService, + private organizationUserService: OrganizationUserService, + private keyService: KeyService, + private encryptService: EncryptService, + private configService: ConfigService, + private accountService: AccountService, + private organizationMetadataService: OrganizationMetadataServiceAbstraction, + ) {} + + async inviteUser( + organization: Organization, + email: string, + type: OrganizationUserType, + permissions?: any, + collections?: any[], + groups?: string[], + ): Promise { + try { + await this.organizationUserApiService.postOrganizationUserInvite(organization.id, { + emails: [email], + type, + accessSecretsManager: false, + collections: collections ?? [], + groups: groups ?? [], + permissions, + }); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async removeUser(organization: Organization, userId: string): Promise { + try { + await this.organizationUserApiService.removeOrganizationUser(organization.id, userId); + this.organizationMetadataService.refreshMetadataCache(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async revokeUser(organization: Organization, userId: string): Promise { + try { + await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId); + this.organizationMetadataService.refreshMetadataCache(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async restoreUser(organization: Organization, userId: string): Promise { + try { + await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId); + this.organizationMetadataService.refreshMetadataCache(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async deleteUser(organization: Organization, userId: string): Promise { + try { + await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId); + this.organizationMetadataService.refreshMetadataCache(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async reinviteUser(organization: Organization, userId: string): Promise { + try { + await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async confirmUser( + user: OrganizationUserView, + publicKey: Uint8Array, + organization: Organization, + ): Promise { + try { + if ( + await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) + ) { + await firstValueFrom( + this.organizationUserService.confirmUser(organization, user, publicKey), + ); + } else { + const request = await firstValueFrom( + this.userId$.pipe( + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => { + if (orgKeys == null || orgKeys[organization.id] == null) { + throw new Error("Organization keys not found for provided User."); + } + return orgKeys[organization.id]; + }), + switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), + map((encKey) => { + const req = new OrganizationUserConfirmRequest(); + req.key = encKey.encryptedString; + return req; + }), + ), + ); + + await this.organizationUserApiService.postOrganizationUserConfirm( + organization.id, + user.id, + request, + ); + } + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message ?? String(error) }; + } + } + + async bulkReinvite(organization: Organization, userIds: string[]): Promise { + try { + const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( + organization.id, + userIds, + ); + return { successful: result, failed: [] }; + } catch (error) { + return { + failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + }; + } + } + + allowResetPassword( + orgUser: OrganizationUserView, + organization: Organization, + resetPasswordEnabled: boolean, + ): boolean { + let callingUserHasPermission = false; + + switch (organization.type) { + case OrganizationUserType.Owner: + callingUserHasPermission = true; + break; + case OrganizationUserType.Admin: + callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner; + break; + case OrganizationUserType.Custom: + callingUserHasPermission = + orgUser.type !== OrganizationUserType.Owner && + orgUser.type !== OrganizationUserType.Admin; + break; + } + + return ( + organization.canManageUsersPassword && + callingUserHasPermission && + organization.useResetPassword && + organization.hasPublicAndPrivateKeys && + orgUser.resetPasswordEnrolled && + resetPasswordEnabled && + orgUser.status === OrganizationUserStatusType.Confirmed + ); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts new file mode 100644 index 00000000000..e478f8bbb41 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts @@ -0,0 +1,640 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { EntityEventsComponent } from "../../../manage/entity-events.component"; +import { AccountRecoveryDialogComponent } from "../../components/account-recovery/account-recovery-dialog.component"; +import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component"; +import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component"; +import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component"; +import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component"; +import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component"; +import { BulkStatusComponent } from "../../components/bulk/bulk-status.component"; +import { + MemberDialogComponent, + MemberDialogResult, + MemberDialogTab, +} from "../../components/member-dialog"; +import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service"; + +import { MemberDialogManagerService } from "./member-dialog-manager.service"; + +describe("MemberDialogManagerService", () => { + let service: MemberDialogManagerService; + let dialogService: MockProxy; + let i18nService: MockProxy; + let toastService: MockProxy; + let userNamePipe: MockProxy; + let deleteManagedMemberWarningService: MockProxy; + + let mockOrganization: Organization; + let mockUser: OrganizationUserView; + let mockBillingMetadata: OrganizationBillingMetadataResponse; + + beforeEach(() => { + dialogService = mock(); + i18nService = mock(); + toastService = mock(); + userNamePipe = mock(); + deleteManagedMemberWarningService = mock(); + + service = new MemberDialogManagerService( + dialogService, + i18nService, + toastService, + userNamePipe, + deleteManagedMemberWarningService, + ); + + // Setup mock data + mockOrganization = { + id: "org-id", + canManageUsers: true, + productTierType: ProductTierType.Enterprise, + } as Organization; + + mockUser = { + id: "user-id", + email: "test@example.com", + name: "Test User", + usesKeyConnector: false, + status: OrganizationUserStatusType.Confirmed, + hasMasterPassword: true, + accessSecretsManager: false, + managedByOrganization: false, + } as OrganizationUserView; + + mockBillingMetadata = { + organizationOccupiedSeats: 10, + isOnSecretsManagerStandalone: false, + } as OrganizationBillingMetadataResponse; + + userNamePipe.transform.mockReturnValue("Test User"); + }); + + describe("openInviteDialog", () => { + it("should open the invite dialog with correct parameters", async () => { + const mockDialogRef = { closed: of(MemberDialogResult.Saved) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const allUserEmails = ["user1@example.com", "user2@example.com"]; + + const result = await service.openInviteDialog( + mockOrganization, + mockBillingMetadata, + allUserEmails, + ); + + expect(dialogService.open).toHaveBeenCalledWith( + MemberDialogComponent, + expect.objectContaining({ + data: { + kind: "Add", + organizationId: mockOrganization.id, + allOrganizationUserEmails: allUserEmails, + occupiedSeatCount: 10, + isOnSecretsManagerStandalone: false, + }, + }), + ); + expect(result).toBe(MemberDialogResult.Saved); + }); + + it("should return Canceled when dialog is closed without result", async () => { + const mockDialogRef = { closed: of(null) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const result = await service.openInviteDialog(mockOrganization, mockBillingMetadata, []); + + expect(result).toBe(MemberDialogResult.Canceled); + }); + + it("should handle null billing metadata", async () => { + const mockDialogRef = { closed: of(MemberDialogResult.Saved) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + await service.openInviteDialog(mockOrganization, null, []); + + expect(dialogService.open).toHaveBeenCalledWith( + MemberDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + occupiedSeatCount: 0, + isOnSecretsManagerStandalone: false, + }), + }), + ); + }); + }); + + describe("openEditDialog", () => { + it("should open the edit dialog with correct parameters", async () => { + const mockDialogRef = { closed: of(MemberDialogResult.Saved) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata); + + expect(dialogService.open).toHaveBeenCalledWith( + MemberDialogComponent, + expect.objectContaining({ + data: { + kind: "Edit", + name: "Test User", + organizationId: mockOrganization.id, + organizationUserId: mockUser.id, + usesKeyConnector: false, + isOnSecretsManagerStandalone: false, + initialTab: MemberDialogTab.Role, + managedByOrganization: false, + }, + }), + ); + expect(result).toBe(MemberDialogResult.Saved); + }); + + it("should use custom initial tab when provided", async () => { + const mockDialogRef = { closed: of(MemberDialogResult.Saved) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + await service.openEditDialog( + mockUser, + mockOrganization, + mockBillingMetadata, + MemberDialogTab.AccountRecovery, + ); + + expect(dialogService.open).toHaveBeenCalledWith( + MemberDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + initialTab: 0, // MemberDialogTab.AccountRecovery is 0 + }), + }), + ); + }); + + it("should return Canceled when dialog is closed without result", async () => { + const mockDialogRef = { closed: of(null) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata); + + expect(result).toBe(MemberDialogResult.Canceled); + }); + }); + + describe("openAccountRecoveryDialog", () => { + it("should open account recovery dialog with correct parameters", async () => { + const mockDialogRef = { closed: of("recovered") }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization); + + expect(dialogService.open).toHaveBeenCalledWith( + AccountRecoveryDialogComponent, + expect.objectContaining({ + data: { + name: "Test User", + email: mockUser.email, + organizationId: mockOrganization.id, + organizationUserId: mockUser.id, + }, + }), + ); + expect(result).toBe("recovered"); + }); + + it("should return Ok when dialog is closed without result", async () => { + const mockDialogRef = { closed: of(null) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization); + + expect(result).toBe("ok"); + }); + }); + + describe("openBulkConfirmDialog", () => { + it("should open bulk confirm dialog with correct parameters", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkConfirmDialog(mockOrganization, users); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkConfirmDialogComponent, + expect.objectContaining({ + data: { + organization: mockOrganization, + users: users, + }, + }), + ); + }); + }); + + describe("openBulkRemoveDialog", () => { + it("should open bulk remove dialog with correct parameters", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkRemoveDialog(mockOrganization, users); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkRemoveDialogComponent, + expect.objectContaining({ + data: { + organizationId: mockOrganization.id, + users: users, + }, + }), + ); + }); + }); + + describe("openBulkDeleteDialog", () => { + it("should open bulk delete dialog when warning already acknowledged", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true)); + + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkDeleteDialog(mockOrganization, users); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkDeleteDialogComponent, + expect.objectContaining({ + data: { + organizationId: mockOrganization.id, + users: users, + }, + }), + ); + expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled(); + }); + + it("should show warning before opening dialog for enterprise organizations", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + deleteManagedMemberWarningService.showWarning.mockResolvedValue(true); + + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkDeleteDialog(mockOrganization, users); + + expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled(); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("should not open dialog if warning is not acknowledged", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + deleteManagedMemberWarningService.showWarning.mockResolvedValue(false); + + const users = [mockUser]; + await service.openBulkDeleteDialog(mockOrganization, users); + + expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled(); + expect(dialogService.open).not.toHaveBeenCalled(); + }); + + it("should skip warning for non-enterprise organizations", async () => { + const nonEnterpriseOrg = { + ...mockOrganization, + productTierType: ProductTierType.Free, + } as Organization; + + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkDeleteDialog(nonEnterpriseOrg, users); + + expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled(); + expect(dialogService.open).toHaveBeenCalled(); + }); + }); + + describe("openBulkRestoreRevokeDialog", () => { + it("should open bulk restore revoke dialog with correct parameters for revoking", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkRestoreRevokeDialog(mockOrganization, users, true); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkRestoreRevokeComponent, + expect.objectContaining({ + data: expect.objectContaining({ + organizationId: mockOrganization.id, + users: users, + isRevoking: true, + }), + }), + ); + }); + + it("should open bulk restore revoke dialog with correct parameters for restoring", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + await service.openBulkRestoreRevokeDialog(mockOrganization, users, false); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkRestoreRevokeComponent, + expect.objectContaining({ + data: expect.objectContaining({ + organizationId: mockOrganization.id, + users: users, + isRevoking: false, + }), + }), + ); + }); + }); + + describe("openBulkEnableSecretsManagerDialog", () => { + it("should open dialog with eligible users only", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const user1 = { ...mockUser, accessSecretsManager: false } as OrganizationUserView; + const user2 = { + ...mockUser, + id: "user-2", + accessSecretsManager: true, + } as OrganizationUserView; + const users = [user1, user2]; + + await service.openBulkEnableSecretsManagerDialog(mockOrganization, users); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkEnableSecretsManagerDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + orgId: mockOrganization.id, + users: [user1], + }), + }), + ); + }); + + it("should show error toast when no eligible users", async () => { + i18nService.t.mockImplementation((key) => key); + + const user1 = { ...mockUser, accessSecretsManager: true } as OrganizationUserView; + const users = [user1]; + + await service.openBulkEnableSecretsManagerDialog(mockOrganization, users); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "noSelectedUsersApplicable", + }); + expect(dialogService.open).not.toHaveBeenCalled(); + }); + }); + + describe("openBulkStatusDialog", () => { + it("should open bulk status dialog with correct parameters", async () => { + const mockDialogRef = { closed: of(undefined) }; + dialogService.open.mockReturnValue(mockDialogRef as any); + + const users = [mockUser]; + const filteredUsers = [mockUser]; + const request = Promise.resolve(); + const successMessage = "Success!"; + + await service.openBulkStatusDialog(users, filteredUsers, request, successMessage); + + expect(dialogService.open).toHaveBeenCalledWith( + BulkStatusComponent, + expect.objectContaining({ + data: { + users: users, + filteredUsers: filteredUsers, + request: request, + successfulMessage: successMessage, + }, + }), + ); + }); + }); + + describe("openEventsDialog", () => { + it("should open events dialog with correct parameters", () => { + service.openEventsDialog(mockUser, mockOrganization); + + expect(dialogService.open).toHaveBeenCalledWith( + EntityEventsComponent, + expect.objectContaining({ + data: { + name: "Test User", + organizationId: mockOrganization.id, + entityId: mockUser.id, + showUser: false, + entity: "user", + }, + }), + ); + }); + }); + + describe("openRemoveUserConfirmationDialog", () => { + it("should return true when user confirms removal", async () => { + dialogService.openSimpleDialog.mockResolvedValue(true); + + const result = await service.openRemoveUserConfirmationDialog(mockUser); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { + key: "removeUserIdAccess", + placeholders: ["Test User"], + }, + content: { key: "removeOrgUserConfirmation" }, + type: "warning", + }); + expect(result).toBe(true); + }); + + it("should show key connector warning when user uses key connector", async () => { + const keyConnectorUser = { ...mockUser, usesKeyConnector: true } as OrganizationUserView; + dialogService.openSimpleDialog.mockResolvedValue(true); + + await service.openRemoveUserConfirmationDialog(keyConnectorUser); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + content: { key: "removeUserConfirmationKeyConnector" }, + }), + ); + }); + + it("should return false when user cancels confirmation", async () => { + dialogService.openSimpleDialog.mockResolvedValue(false); + + const result = await service.openRemoveUserConfirmationDialog(mockUser); + + expect(result).toBe(false); + }); + + it("should show no master password warning for confirmed users without master password", async () => { + const noMpUser = { + ...mockUser, + status: OrganizationUserStatusType.Confirmed, + hasMasterPassword: false, + } as OrganizationUserView; + + dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + + const result = await service.openRemoveUserConfirmationDialog(noMpUser); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2); + expect(dialogService.openSimpleDialog).toHaveBeenLastCalledWith({ + title: { + key: "removeOrgUserNoMasterPasswordTitle", + }, + content: { + key: "removeOrgUserNoMasterPasswordDesc", + placeholders: ["Test User"], + }, + type: "warning", + }); + expect(result).toBe(true); + }); + }); + + describe("openRevokeUserConfirmationDialog", () => { + it("should return true when user confirms revocation", async () => { + i18nService.t.mockReturnValue("Revoke user confirmation"); + dialogService.openSimpleDialog.mockResolvedValue(true); + + const result = await service.openRevokeUserConfirmationDialog(mockUser); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "revokeAccess", placeholders: ["Test User"] }, + content: "Revoke user confirmation", + acceptButtonText: { key: "revokeAccess" }, + type: "warning", + }); + expect(result).toBe(true); + }); + + it("should return false when user cancels confirmation", async () => { + i18nService.t.mockReturnValue("Revoke user confirmation"); + dialogService.openSimpleDialog.mockResolvedValue(false); + + const result = await service.openRevokeUserConfirmationDialog(mockUser); + + expect(result).toBe(false); + }); + + it("should show no master password warning for confirmed users without master password", async () => { + const noMpUser = { + ...mockUser, + status: OrganizationUserStatusType.Confirmed, + hasMasterPassword: false, + } as OrganizationUserView; + + i18nService.t.mockReturnValue("Revoke user confirmation"); + dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + + const result = await service.openRevokeUserConfirmationDialog(noMpUser); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + }); + + describe("openDeleteUserConfirmationDialog", () => { + it("should return true when user confirms deletion", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true)); + dialogService.openSimpleDialog.mockResolvedValue(true); + + const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { + key: "deleteOrganizationUser", + placeholders: ["Test User"], + }, + content: { + key: "deleteOrganizationUserWarningDesc", + placeholders: ["Test User"], + }, + type: "warning", + acceptButtonText: { key: "delete" }, + cancelButtonText: { key: "cancel" }, + }); + expect(deleteManagedMemberWarningService.acknowledgeWarning).toHaveBeenCalledWith( + mockOrganization.id, + ); + expect(result).toBe(true); + }); + + it("should show warning before deletion for enterprise organizations", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + deleteManagedMemberWarningService.showWarning.mockResolvedValue(true); + dialogService.openSimpleDialog.mockResolvedValue(true); + + const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization); + + expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should return false if warning is not acknowledged", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + deleteManagedMemberWarningService.showWarning.mockResolvedValue(false); + + const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization); + + expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should skip warning for non-enterprise organizations", async () => { + const nonEnterpriseOrg = { + ...mockOrganization, + productTierType: ProductTierType.Free, + } as Organization; + + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false)); + dialogService.openSimpleDialog.mockResolvedValue(true); + + const result = await service.openDeleteUserConfirmationDialog(mockUser, nonEnterpriseOrg); + + expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should return false when user cancels confirmation", async () => { + deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true)); + dialogService.openSimpleDialog.mockResolvedValue(false); + + const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization); + + expect(result).toBe(false); + expect(deleteManagedMemberWarningService.acknowledgeWarning).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts new file mode 100644 index 00000000000..c6ef536af2b --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts @@ -0,0 +1,322 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { openEntityEventsDialog } from "../../../manage/entity-events.component"; +import { + AccountRecoveryDialogComponent, + AccountRecoveryDialogResultType, +} from "../../components/account-recovery/account-recovery-dialog.component"; +import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component"; +import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component"; +import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component"; +import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component"; +import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component"; +import { BulkStatusComponent } from "../../components/bulk/bulk-status.component"; +import { + MemberDialogResult, + MemberDialogTab, + openUserAddEditDialog, +} from "../../components/member-dialog"; +import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service"; + +@Injectable() +export class MemberDialogManagerService { + constructor( + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private userNamePipe: UserNamePipe, + private deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + ) {} + + async openInviteDialog( + organization: Organization, + billingMetadata: OrganizationBillingMetadataResponse, + allUserEmails: string[], + ): Promise { + const dialog = openUserAddEditDialog(this.dialogService, { + data: { + kind: "Add", + organizationId: organization.id, + allOrganizationUserEmails: allUserEmails, + occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0, + isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, + }, + }); + + const result = await lastValueFrom(dialog.closed); + return result ?? MemberDialogResult.Canceled; + } + + async openEditDialog( + user: OrganizationUserView, + organization: Organization, + billingMetadata: OrganizationBillingMetadataResponse, + initialTab: MemberDialogTab = MemberDialogTab.Role, + ): Promise { + const dialog = openUserAddEditDialog(this.dialogService, { + data: { + kind: "Edit", + name: this.userNamePipe.transform(user), + organizationId: organization.id, + organizationUserId: user.id, + usesKeyConnector: user.usesKeyConnector, + isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, + initialTab: initialTab, + managedByOrganization: user.managedByOrganization, + }, + }); + + const result = await lastValueFrom(dialog.closed); + return result ?? MemberDialogResult.Canceled; + } + + async openAccountRecoveryDialog( + user: OrganizationUserView, + organization: Organization, + ): Promise { + const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + email: user.email, + organizationId: organization.id as OrganizationId, + organizationUserId: user.id, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + return result ?? AccountRecoveryDialogResultType.Ok; + } + + async openBulkConfirmDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { + data: { + organization: organization, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkRemoveDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkDeleteDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + const warningAcknowledged = await firstValueFrom( + this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), + ); + + if ( + !warningAcknowledged && + organization.canManageUsers && + organization.productTierType === ProductTierType.Enterprise + ) { + const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); + if (!acknowledged) { + return; + } + } + + const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkRestoreRevokeDialog( + organization: Organization, + users: OrganizationUserView[], + isRevoking: boolean, + ): Promise { + const ref = BulkRestoreRevokeComponent.open(this.dialogService, { + organizationId: organization.id, + users: users, + isRevoking: isRevoking, + }); + + await firstValueFrom(ref.closed); + } + + async openBulkEnableSecretsManagerDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + const eligibleUsers = users.filter((ou) => !ou.accessSecretsManager); + + if (eligibleUsers.length === 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, { + orgId: organization.id, + users: eligibleUsers, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkStatusDialog( + users: OrganizationUserView[], + filteredUsers: OrganizationUserView[], + request: Promise, + successMessage: string, + ): Promise { + const dialogRef = BulkStatusComponent.open(this.dialogService, { + data: { + users: users, + filteredUsers: filteredUsers, + request: request, + successfulMessage: successMessage, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + openEventsDialog(user: OrganizationUserView, organization: Organization): void { + openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + organizationId: organization.id, + entityId: user.id, + showUser: false, + entity: "user", + }, + }); + } + + async openRemoveUserConfirmationDialog(user: OrganizationUserView): Promise { + const content = user.usesKeyConnector + ? "removeUserConfirmationKeyConnector" + : "removeOrgUserConfirmation"; + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { + key: "removeUserIdAccess", + placeholders: [this.userNamePipe.transform(user)], + }, + content: { key: content }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + if (user.status > 0 && user.hasMasterPassword === false) { + return await this.openNoMasterPasswordConfirmationDialog(user); + } + + return true; + } + + async openRevokeUserConfirmationDialog(user: OrganizationUserView): Promise { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] }, + content: this.i18nService.t("revokeUserConfirmation"), + acceptButtonText: { key: "revokeAccess" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + if (user.status > 0 && user.hasMasterPassword === false) { + return await this.openNoMasterPasswordConfirmationDialog(user); + } + + return true; + } + + async openDeleteUserConfirmationDialog( + user: OrganizationUserView, + organization: Organization, + ): Promise { + const warningAcknowledged = await firstValueFrom( + this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), + ); + + if ( + !warningAcknowledged && + organization.canManageUsers && + organization.productTierType === ProductTierType.Enterprise + ) { + const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); + if (!acknowledged) { + return false; + } + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { + key: "deleteOrganizationUser", + placeholders: [this.userNamePipe.transform(user)], + }, + content: { + key: "deleteOrganizationUserWarningDesc", + placeholders: [this.userNamePipe.transform(user)], + }, + type: "warning", + acceptButtonText: { key: "delete" }, + cancelButtonText: { key: "cancel" }, + }); + + if (confirmed) { + await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id); + } + + return confirmed; + } + + private async openNoMasterPasswordConfirmationDialog( + user: OrganizationUserView, + ): Promise { + return this.dialogService.openSimpleDialog({ + title: { + key: "removeOrgUserNoMasterPasswordTitle", + }, + content: { + key: "removeOrgUserNoMasterPasswordDesc", + placeholders: [this.userNamePipe.transform(user)], + }, + type: "warning", + }); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts new file mode 100644 index 00000000000..615d2ece463 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts @@ -0,0 +1,362 @@ +import { TestBed } from "@angular/core/testing"; + +import { + OrganizationUserApiService, + OrganizationUserUserDetailsResponse, +} from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { GroupApiService } from "../../../core"; + +import { OrganizationMembersService } from "./organization-members.service"; + +describe("OrganizationMembersService", () => { + let service: OrganizationMembersService; + let organizationUserApiService: jest.Mocked; + let groupService: jest.Mocked; + let apiService: jest.Mocked; + + const mockOrganizationId = "org-123" as OrganizationId; + + const createMockOrganization = (overrides: Partial = {}): Organization => { + const org = new Organization(); + org.id = mockOrganizationId; + org.useGroups = false; + + return Object.assign(org, overrides); + }; + + const createMockUserResponse = ( + overrides: Partial = {}, + ): OrganizationUserUserDetailsResponse => { + return { + id: "user-1", + userId: "user-id-1", + email: "test@example.com", + name: "Test User", + collections: [], + groups: [], + ...overrides, + } as OrganizationUserUserDetailsResponse; + }; + + const createMockGroup = (id: string, name: string) => ({ + id, + name, + }); + + const createMockCollection = (id: string, name: string) => ({ + id, + name, + }); + + beforeEach(() => { + organizationUserApiService = { + getAllUsers: jest.fn(), + } as any; + + groupService = { + getAll: jest.fn(), + } as any; + + apiService = { + getCollections: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + OrganizationMembersService, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: GroupApiService, useValue: groupService }, + { provide: ApiService, useValue: apiService }, + ], + }); + + service = TestBed.inject(OrganizationMembersService); + }); + + describe("loadUsers", () => { + it("should load users with collections when organization does not use groups", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUser = createMockUserResponse({ + collections: [{ id: "col-1" } as any], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockCollections = [createMockCollection("col-1", "Collection 1")]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: mockCollections, + } as any); + + const result = await service.loadUsers(organization); + + expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, { + includeGroups: false, + includeCollections: true, + }); + expect(apiService.getCollections).toHaveBeenCalledWith(mockOrganizationId); + expect(groupService.getAll).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].collectionNames).toEqual(["Collection 1"]); + expect(result[0].groupNames).toEqual([]); + }); + + it("should load users with groups when organization uses groups", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUser = createMockUserResponse({ + groups: ["group-1", "group-2"], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockGroups = [ + createMockGroup("group-1", "Group 1"), + createMockGroup("group-2", "Group 2"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + groupService.getAll.mockResolvedValue(mockGroups as any); + + const result = await service.loadUsers(organization); + + expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, { + includeGroups: true, + includeCollections: false, + }); + expect(groupService.getAll).toHaveBeenCalledWith(mockOrganizationId); + expect(apiService.getCollections).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].groupNames).toEqual(["Group 1", "Group 2"]); + expect(result[0].collectionNames).toEqual([]); + }); + + it("should sort group names alphabetically", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUser = createMockUserResponse({ + groups: ["group-1", "group-2", "group-3"], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockGroups = [ + createMockGroup("group-1", "Zebra Group"), + createMockGroup("group-2", "Alpha Group"), + createMockGroup("group-3", "Beta Group"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + groupService.getAll.mockResolvedValue(mockGroups as any); + + const result = await service.loadUsers(organization); + + expect(result[0].groupNames).toEqual(["Alpha Group", "Beta Group", "Zebra Group"]); + }); + + it("should sort collection names alphabetically", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUser = createMockUserResponse({ + collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockCollections = [ + createMockCollection("col-1", "Zebra Collection"), + createMockCollection("col-2", "Alpha Collection"), + createMockCollection("col-3", "Beta Collection"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: mockCollections, + } as any); + + const result = await service.loadUsers(organization); + + expect(result[0].collectionNames).toEqual([ + "Alpha Collection", + "Beta Collection", + "Zebra Collection", + ]); + }); + + it("should filter out null or undefined group names", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUser = createMockUserResponse({ + groups: ["group-1", "group-2", "group-3"], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockGroups = [ + createMockGroup("group-1", "Group 1"), + // group-2 is missing - should be filtered out + createMockGroup("group-3", "Group 3"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + groupService.getAll.mockResolvedValue(mockGroups as any); + + const result = await service.loadUsers(organization); + + expect(result[0].groupNames).toEqual(["Group 1", "Group 3"]); + expect(result[0].groupNames).not.toContain(undefined); + expect(result[0].groupNames).not.toContain(null); + }); + + it("should filter out null or undefined collection names", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUser = createMockUserResponse({ + collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + const mockCollections = [ + createMockCollection("col-1", "Collection 1"), + // col-2 is missing - should be filtered out + createMockCollection("col-3", "Collection 3"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: mockCollections, + } as any); + + const result = await service.loadUsers(organization); + + expect(result[0].collectionNames).toEqual(["Collection 1", "Collection 3"]); + expect(result[0].collectionNames).not.toContain(undefined); + expect(result[0].collectionNames).not.toContain(null); + }); + + it("should handle multiple users", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUser1 = createMockUserResponse({ + id: "user-1", + groups: ["group-1"], + }); + const mockUser2 = createMockUserResponse({ + id: "user-2", + groups: ["group-2"], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser1, mockUser2], + } as any; + const mockGroups = [ + createMockGroup("group-1", "Group 1"), + createMockGroup("group-2", "Group 2"), + ]; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + groupService.getAll.mockResolvedValue(mockGroups as any); + + const result = await service.loadUsers(organization); + + expect(result).toHaveLength(2); + expect(result[0].groupNames).toEqual(["Group 1"]); + expect(result[1].groupNames).toEqual(["Group 2"]); + }); + + it("should return empty array when usersResponse.data is null", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUsersResponse: ListResponse = { + data: null as any, + } as any; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: [], + } as any); + + const result = await service.loadUsers(organization); + + expect(result).toEqual([]); + }); + + it("should return empty array when usersResponse.data is undefined", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUsersResponse: ListResponse = { + data: undefined as any, + } as any; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: [], + } as any); + + const result = await service.loadUsers(organization); + + expect(result).toEqual([]); + }); + + it("should handle empty groups array", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUser = createMockUserResponse({ + groups: [], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + groupService.getAll.mockResolvedValue([]); + + const result = await service.loadUsers(organization); + + expect(result).toHaveLength(1); + expect(result[0].groupNames).toEqual([]); + }); + + it("should handle empty collections array", async () => { + const organization = createMockOrganization({ useGroups: false }); + const mockUser = createMockUserResponse({ + collections: [], + }); + const mockUsersResponse: ListResponse = { + data: [mockUser], + } as any; + + organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); + apiService.getCollections.mockResolvedValue({ + data: [], + } as any); + + const result = await service.loadUsers(organization); + + expect(result).toHaveLength(1); + expect(result[0].collectionNames).toEqual([]); + }); + + it("should fetch data in parallel using Promise.all", async () => { + const organization = createMockOrganization({ useGroups: true }); + const mockUsersResponse: ListResponse = { + data: [], + } as any; + + let getUsersCallTime: number; + let getGroupsCallTime: number; + + organizationUserApiService.getAllUsers.mockImplementation(async () => { + getUsersCallTime = Date.now(); + return mockUsersResponse; + }); + + groupService.getAll.mockImplementation(async () => { + getGroupsCallTime = Date.now(); + return []; + }); + + await service.loadUsers(organization); + + // Both calls should have been initiated at roughly the same time (within 50ms) + expect(Math.abs(getUsersCallTime - getGroupsCallTime)).toBeLessThan(50); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts new file mode 100644 index 00000000000..613c7c1b9c0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from "@angular/core"; + +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +import { GroupApiService } from "../../../core"; +import { OrganizationUserView } from "../../../core/views/organization-user.view"; + +@Injectable() +export class OrganizationMembersService { + constructor( + private organizationUserApiService: OrganizationUserApiService, + private groupService: GroupApiService, + private apiService: ApiService, + ) {} + + async loadUsers(organization: Organization): Promise { + let groupsPromise: Promise> | undefined; + let collectionsPromise: Promise> | undefined; + + const userPromise = this.organizationUserApiService.getAllUsers(organization.id, { + includeGroups: organization.useGroups, + includeCollections: !organization.useGroups, + }); + + if (organization.useGroups) { + groupsPromise = this.getGroupNameMap(organization); + } else { + collectionsPromise = this.getCollectionNameMap(organization); + } + + const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([ + userPromise, + groupsPromise, + collectionsPromise, + ]); + + return ( + usersResponse.data?.map((r) => { + const userView = OrganizationUserView.fromResponse(r); + + userView.groupNames = userView.groups + .map((g: string) => groupNamesMap?.get(g)) + .filter((name): name is string => name != null) + .sort(); + userView.collectionNames = userView.collections + .map((c: { id: string }) => collectionNamesMap?.get(c.id)) + .filter((name): name is string => name != null) + .sort(); + + return userView; + }) ?? [] + ); + } + + private async getGroupNameMap(organization: Organization): Promise> { + const groups = await this.groupService.getAll(organization.id); + const groupNameMap = new Map(); + groups.forEach((g: { id: string; name: string }) => groupNameMap.set(g.id, g.name)); + return groupNameMap; + } + + private async getCollectionNameMap(organization: Organization): Promise> { + const response = this.apiService + .getCollections(organization.id) + .then((res) => + res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })), + ); + + const collections = await response; + const collectionMap = new Map(); + collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name)); + return collectionMap; + } +} diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts new file mode 100644 index 00000000000..f7bb510f579 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts @@ -0,0 +1,461 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { of } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../organizations/change-plan-dialog.component"; + +import { BillingConstraintService, SeatLimitResult } from "./billing-constraint.service"; + +jest.mock("../../organizations/change-plan-dialog.component"); + +describe("BillingConstraintService", () => { + let service: BillingConstraintService; + let i18nService: jest.Mocked; + let dialogService: jest.Mocked; + let toastService: jest.Mocked; + let router: jest.Mocked; + let organizationMetadataService: jest.Mocked; + + const mockOrganizationId = "org-123" as OrganizationId; + + const createMockOrganization = (overrides: Partial = {}): Organization => { + const org = new Organization(); + org.id = mockOrganizationId; + org.seats = 10; + org.productTierType = ProductTierType.Teams; + + Object.defineProperty(org, "hasReseller", { + value: false, + writable: true, + configurable: true, + }); + + Object.defineProperty(org, "canEditSubscription", { + value: true, + writable: true, + configurable: true, + }); + + return Object.assign(org, overrides); + }; + + const createMockBillingMetadata = ( + overrides: Partial = {}, + ): OrganizationBillingMetadataResponse => { + return { + organizationOccupiedSeats: 5, + ...overrides, + } as OrganizationBillingMetadataResponse; + }; + + beforeEach(() => { + const mockDialogRef = { + closed: of(true), + }; + + const mockSimpleDialogRef = { + closed: of(true), + }; + + i18nService = { + t: jest.fn().mockReturnValue("translated-text"), + } as any; + + dialogService = { + openSimpleDialogRef: jest.fn().mockReturnValue(mockSimpleDialogRef), + } as any; + + toastService = { + showToast: jest.fn(), + } as any; + + router = { + navigate: jest.fn().mockResolvedValue(true), + } as any; + + organizationMetadataService = { + getOrganizationMetadata$: jest.fn(), + refreshMetadataCache: jest.fn(), + } as any; + + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + TestBed.configureTestingModule({ + providers: [ + BillingConstraintService, + { provide: I18nService, useValue: i18nService }, + { provide: DialogService, useValue: dialogService }, + { provide: ToastService, useValue: toastService }, + { provide: Router, useValue: router }, + { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService }, + ], + }); + + service = TestBed.inject(BillingConstraintService); + }); + + describe("checkSeatLimit", () => { + it("should allow users when occupied seats are less than total seats", () => { + const organization = createMockOrganization({ seats: 10 }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 5 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ canAddUsers: true }); + }); + + it("should allow users when occupied seats equal total seats for non-fixed seat plans", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.Teams, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ canAddUsers: true }); + }); + + it("should block users with reseller-limit reason when organization has reseller", () => { + const organization = createMockOrganization({ + seats: 10, + hasReseller: true, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "reseller-limit", + }); + }); + + it("should block users with fixed-seat-limit reason for fixed seat plans", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.Free, + canEditSubscription: true, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }); + }); + + it("should not show upgrade dialog when organization cannot edit subscription", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.TeamsStarter, + canEditSubscription: false, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }); + }); + + it("shoud throw if missing billingMetadata", () => { + const organization = createMockOrganization({ seats: 10 }); + const billingMetadata = createMockBillingMetadata({ + organizationOccupiedSeats: undefined as any, + }); + + const err = () => service.checkSeatLimit(organization, billingMetadata); + + expect(err).toThrow("Cannot check seat limit: billingMetadata is null or undefined."); + }); + }); + + describe("seatLimitReached", () => { + it("should return false when canAddUsers is true", async () => { + const result: SeatLimitResult = { canAddUsers: true }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(false); + }); + + it("should show toast and return true for reseller-limit", async () => { + const result: SeatLimitResult = { canAddUsers: false, reason: "reseller-limit" }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "translated-text", + message: "translated-text", + }); + expect(i18nService.t).toHaveBeenCalledWith("seatLimitReached"); + expect(i18nService.t).toHaveBeenCalledWith("contactYourProvider"); + expect(seatLimitReached).toBe(true); + }); + + it("should return true when upgrade dialog is cancelled", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }; + const organization = createMockOrganization(); + const mockDialogRef = { closed: of(ChangePlanDialogResultType.Closed) }; + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + productTierType: organization.productTierType, + }, + }); + expect(seatLimitReached).toBe(true); + }); + + it("should return false when upgrade dialog is submitted", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }; + const organization = createMockOrganization(); + const mockDialogRef = { closed: of(ChangePlanDialogResultType.Submitted) }; + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(false); + }); + + it("should show seat limit dialog when shouldShowUpgradeDialog is false", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: false, + productTierType: ProductTierType.Free, + }); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(dialogService.openSimpleDialogRef).toHaveBeenCalled(); + expect(seatLimitReached).toBe(true); + }); + + it("should return true for unknown reasons", async () => { + const result: SeatLimitResult = { canAddUsers: false }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(true); + }); + }); + + describe("navigateToPaymentMethod", () => { + it("should navigate to payment method with correct parameters", async () => { + const organization = createMockOrganization(); + + await service.navigateToPaymentMethod(organization); + + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", organization.id, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + }); + }); + + describe("private methods through public method coverage", () => { + describe("getDialogContent via showSeatLimitReachedDialog", () => { + it("should get correct dialog content for Free organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Free, + canEditSubscription: false, + seats: 5, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("freeOrgInvLimitReachedNoManageBilling", 5); + }); + + it("should get correct dialog content for TeamsStarter organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.TeamsStarter, + canEditSubscription: false, + seats: 3, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith( + "teamsStarterPlanInvLimitReachedNoManageBilling", + 3, + ); + }); + + it("should get correct dialog content for Families organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Families, + canEditSubscription: false, + seats: 6, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("familiesPlanInvLimitReachedNoManageBilling", 6); + }); + + it("should throw error for unsupported product type in getProductKey", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Enterprise, + canEditSubscription: false, + }); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + + describe("getAcceptButtonText via showSeatLimitReachedDialog", () => { + it("should return 'ok' when organization cannot edit subscription", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: false, + productTierType: ProductTierType.Free, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("ok"); + }); + + it("should return 'upgrade' when organization can edit subscription", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Free, + }); + const mockSimpleDialogRef = { closed: of(false) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("upgrade"); + }); + + it("should throw error for unsupported product type in getAcceptButtonText", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Enterprise, + }); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + + describe("handleUpgradeNavigation", () => { + it("should navigate to billing subscription with upgrade query param", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Free, + }); + const mockSimpleDialogRef = { closed: of(true) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await service.seatLimitReached(result, organization); + + expect(router.navigate).toHaveBeenCalledWith( + ["/organizations", organization.id, "billing", "subscription"], + { queryParams: { upgrade: true } }, + ); + }); + + it("should throw error for non-self-upgradable product type", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Enterprise, + }); + const mockSimpleDialogRef = { closed: of(true) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts new file mode 100644 index 00000000000..d43c2e68497 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { lastValueFrom } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { isFixedSeatPlan } from "../../../admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../organizations/change-plan-dialog.component"; + +export interface SeatLimitResult { + canAddUsers: boolean; + reason?: "reseller-limit" | "fixed-seat-limit" | "no-billing-permission"; + shouldShowUpgradeDialog?: boolean; +} + +@Injectable() +export class BillingConstraintService { + constructor( + private i18nService: I18nService, + private dialogService: DialogService, + private toastService: ToastService, + private router: Router, + ) {} + + checkSeatLimit( + organization: Organization, + billingMetadata: OrganizationBillingMetadataResponse, + ): SeatLimitResult { + const occupiedSeats = billingMetadata?.organizationOccupiedSeats; + if (occupiedSeats == null) { + throw new Error("Cannot check seat limit: billingMetadata is null or undefined."); + } + const totalSeats = organization.seats; + + if (occupiedSeats < totalSeats) { + return { canAddUsers: true }; + } + + if (organization.hasReseller) { + return { + canAddUsers: false, + reason: "reseller-limit", + }; + } + + if (isFixedSeatPlan(organization.productTierType)) { + return { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: organization.canEditSubscription, + }; + } + + return { canAddUsers: true }; + } + + async seatLimitReached(result: SeatLimitResult, organization: Organization): Promise { + if (result.canAddUsers) { + return false; + } + + switch (result.reason) { + case "reseller-limit": + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("seatLimitReached"), + message: this.i18nService.t("contactYourProvider"), + }); + return true; + + case "fixed-seat-limit": + if (result.shouldShowUpgradeDialog) { + const dialogResult = await this.showChangePlanDialog(organization); + // If the plan was successfully changed, the seat limit is no longer blocking + return dialogResult !== ChangePlanDialogResultType.Submitted; + } else { + await this.showSeatLimitReachedDialog(organization); + return true; + } + + default: + return true; + } + } + + private async showChangePlanDialog( + organization: Organization, + ): Promise { + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: organization.id, + productTierType: organization.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + if (result == null) { + throw new Error("ChangePlanDialog result is null or undefined."); + } + + return result; + } + + private async showSeatLimitReachedDialog(organization: Organization): Promise { + const dialogContent = this.getSeatLimitReachedDialogContent(organization); + const acceptButtonText = this.getSeatLimitReachedDialogAcceptButtonText(organization); + + const orgUpgradeSimpleDialogOpts = { + title: this.i18nService.t("upgradeOrganization"), + content: dialogContent, + type: "primary" as const, + acceptButtonText, + cancelButtonText: organization.canEditSubscription ? undefined : (null as string | null), + }; + + const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); + const result = await lastValueFrom(simpleDialog.closed); + + if (result && organization.canEditSubscription) { + await this.handleUpgradeNavigation(organization); + } + } + + private async handleUpgradeNavigation(organization: Organization): Promise { + const productType = organization.productTierType; + + if (isNotSelfUpgradable(productType)) { + throw new Error(`Unsupported product type: ${organization.productTierType}`); + } + + await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); + } + + private getSeatLimitReachedDialogContent(organization: Organization): string { + const productKey = this.getProductKey(organization); + return this.i18nService.t(productKey, organization.seats); + } + + private getSeatLimitReachedDialogAcceptButtonText(organization: Organization): string { + if (!organization.canEditSubscription) { + return this.i18nService.t("ok"); + } + + const productType = organization.productTierType; + + if (isNotSelfUpgradable(productType)) { + throw new Error(`Unsupported product type: ${productType}`); + } + + return this.i18nService.t("upgrade"); + } + + private getProductKey(organization: Organization): string { + const manageBillingText = organization.canEditSubscription + ? "ManageBilling" + : "NoManageBilling"; + + let product = ""; + switch (organization.productTierType) { + case ProductTierType.Free: + product = "freeOrg"; + break; + case ProductTierType.TeamsStarter: + product = "teamsStarterPlan"; + break; + case ProductTierType.Families: + product = "familiesPlan"; + break; + default: + throw new Error(`Unsupported product type: ${organization.productTierType}`); + } + return `${product}InvLimitReached${manageBillingText}`; + } + + async navigateToPaymentMethod(organization: Organization): Promise { + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index affcfce9c17..e86956dec93 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -30,6 +30,7 @@ import { } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; +import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service"; import { AddEditMemberDialogComponent, @@ -199,16 +200,27 @@ export class MembersComponent extends BaseMembersComponent { await this.load(); } - async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { - const providerKey = await this.keyService.getProviderKey(this.providerId); - const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); - const request = new ProviderUserConfirmRequest(); - request.key = key.encryptedString; - await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { + try { + const providerKey = await this.keyService.getProviderKey(this.providerId); + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); + const request = new ProviderUserConfirmRequest(); + request.key = key.encryptedString; + await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } } - removeUser = (id: string): Promise => - this.apiService.deleteProviderUser(this.providerId, id); + removeUser = async (id: string): Promise => { + try { + await this.apiService.deleteProviderUser(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; edit = async (user: ProviderUser | null): Promise => { const data: AddEditMemberDialogParams = { @@ -251,6 +263,12 @@ export class MembersComponent extends BaseMembersComponent { getUsers = (): Promise> => this.apiService.getProviderUsers(this.providerId); - reinviteUser = (id: string): Promise => - this.apiService.postProviderUserReinvite(this.providerId, id); + reinviteUser = async (id: string): Promise => { + try { + await this.apiService.postProviderUserReinvite(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; } diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts index 0ed60bef605..c67f4aed175 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -208,7 +208,7 @@ describe("DefaultOrganizationMetadataService", () => { }, 10); }); - it("does not trigger refresh when feature flag is disabled", async () => { + it("does trigger refresh when feature flag is disabled", async () => { featureFlagSubject.next(false); const mockResponse1 = createMockMetadataResponse(false, 10); @@ -232,11 +232,10 @@ describe("DefaultOrganizationMetadataService", () => { service.refreshMetadataCache(); - // wait to ensure no additional invocations await new Promise((resolve) => setTimeout(resolve, 10)); - expect(invocationCount).toBe(1); - expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + expect(invocationCount).toBe(2); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); subscription.unsubscribe(); }); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts index 09aaa202112..fe96f0d984c 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -1,4 +1,4 @@ -import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs"; +import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; @@ -18,57 +18,56 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS private billingApiService: BillingApiServiceAbstraction, private configService: ConfigService, ) {} - private refreshMetadataTrigger = new Subject(); + private refreshMetadataTrigger = new BehaviorSubject(undefined); - refreshMetadataCache = () => this.refreshMetadataTrigger.next(); + refreshMetadataCache = () => { + this.metadataCache.clear(); + this.refreshMetadataTrigger.next(); + }; - getOrganizationMetadata$ = ( - organizationId: OrganizationId, - ): Observable => - this.configService - .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure) - .pipe( - switchMap((featureFlagEnabled) => { - return merge( - this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled), - this.refreshMetadataTrigger.pipe( - filter(() => featureFlagEnabled), - switchMap(() => - this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true), - ), - ), - ); - }), - ); + getOrganizationMetadata$(orgId: OrganizationId): Observable { + return combineLatest([ + this.refreshMetadataTrigger, + this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure), + ]).pipe( + switchMap(([_, featureFlagEnabled]) => + featureFlagEnabled + ? this.vNextGetOrganizationMetadataInternal$(orgId) + : this.getOrganizationMetadataInternal$(orgId), + ), + ); + } - private getOrganizationMetadataInternal$( - organizationId: OrganizationId, - featureFlagEnabled: boolean, - bypassCache: boolean = false, + private vNextGetOrganizationMetadataInternal$( + orgId: OrganizationId, ): Observable { - if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) { - return this.metadataCache.get(organizationId)!; + const cacheHit = this.metadataCache.get(orgId); + if (cacheHit) { + return cacheHit; } - const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe( + const result = from(this.fetchMetadata(orgId, true)).pipe( shareReplay({ bufferSize: 1, refCount: false }), ); - if (featureFlagEnabled) { - this.metadataCache.set(organizationId, metadata$); - } + this.metadataCache.set(orgId, result); + return result; + } - return metadata$; + private getOrganizationMetadataInternal$( + organizationId: OrganizationId, + ): Observable { + return from(this.fetchMetadata(organizationId, false)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); } private async fetchMetadata( organizationId: OrganizationId, featureFlagEnabled: boolean, ): Promise { - if (featureFlagEnabled) { - return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId); - } - - return await this.billingApiService.getOrganizationBillingMetadata(organizationId); + return featureFlagEnabled + ? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId) + : await this.billingApiService.getOrganizationBillingMetadata(organizationId); } } From 3790e09673aa37fde19d7097514a6d323cbe5f53 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 23 Oct 2025 17:25:48 +0200 Subject: [PATCH 20/35] AC - Prefer signal & change detection (#16948) * Modernize Angular * Remove conflicted files --- .../bulk-collections-dialog.component.ts | 2 + .../collection-access-restricted.component.ts | 8 ++++ .../collection-name.badge.component.ts | 6 +++ .../group-badge/group-name-badge.component.ts | 6 +++ .../vault-filter/vault-filter.component.ts | 4 ++ .../vault-header/vault-header.component.ts | 22 ++++++++++ .../collections/vault.component.ts | 4 ++ .../organization-information.component.ts | 14 ++++++ .../guards/is-enterprise-org.guard.spec.ts | 6 +++ .../guards/is-paid-org.guard.spec.ts | 6 +++ .../guards/org-redirect.guard.spec.ts | 6 +++ .../layouts/organization-layout.component.ts | 2 + .../manage/entity-events.component.ts | 2 + .../organizations/manage/events.component.ts | 2 + .../manage/group-add-edit.component.ts | 2 + .../organizations/manage/groups.component.ts | 2 + .../manage/user-confirm.component.ts | 2 + .../verify-recover-delete-org.component.ts | 2 + .../account-recovery-dialog.component.ts | 4 ++ .../bulk/bulk-confirm-dialog.component.ts | 2 + .../bulk/bulk-delete-dialog.component.ts | 2 + .../bulk/bulk-enable-sm-dialog.component.ts | 2 + .../bulk/bulk-remove-dialog.component.ts | 2 + .../bulk/bulk-restore-revoke.component.ts | 2 + .../components/bulk/bulk-status.component.ts | 2 + .../member-dialog/member-dialog.component.ts | 2 + .../nested-checkbox.component.ts | 6 +++ .../members/members.component.ts | 2 + .../policies/base-policy-edit.component.ts | 4 ++ .../policies/policies.component.ts | 2 + .../autotype-policy.component.ts | 2 + .../disable-send.component.ts | 2 + .../master-password.component.ts | 2 + .../organization-data-ownership.component.ts | 2 + .../password-generator.component.ts | 2 + .../remove-unlock-with-pin.component.ts | 2 + .../require-sso.component.ts | 2 + .../reset-password.component.ts | 2 + .../restricted-item-types.component.ts | 2 + .../send-options.component.ts | 2 + .../single-org.component.ts | 2 + .../two-factor-authentication.component.ts | 2 + ...t-organization-data-ownership.component.ts | 4 ++ .../policies/policy-edit-dialog.component.ts | 4 ++ .../reporting/reports-home.component.ts | 2 + .../settings/account.component.ts | 2 + .../delete-organization-dialog.component.ts | 2 + .../settings/two-factor-setup.component.ts | 2 + .../access-selector.component.ts | 43 ++++++++++++++++--- .../collection-dialog.component.ts | 2 + .../accept-family-sponsorship.component.ts | 2 + ...families-for-enterprise-setup.component.ts | 4 ++ .../settings/create-organization.component.ts | 2 + .../device-approvals.component.ts | 2 + .../domain-add-edit-dialog.component.ts | 2 + .../domain-verification.component.ts | 2 + .../organizations/manage/scim.component.ts | 2 + .../activate-autofill.component.ts | 2 + .../automatic-app-login.component.ts | 2 + ...disable-personal-vault-export.component.ts | 2 + .../maximum-vault-timeout.component.ts | 2 + .../manage/accept-provider.component.ts | 2 + .../add-edit-member-dialog.component.ts | 2 + .../dialogs/bulk-confirm-dialog.component.ts | 2 + .../dialogs/bulk-remove-dialog.component.ts | 2 + .../providers/manage/events.component.ts | 2 + .../providers/manage/members.component.ts | 2 + .../providers/providers-layout.component.ts | 2 + .../providers/providers.component.ts | 2 + .../providers/settings/account.component.ts | 2 + .../setup/setup-provider.component.ts | 2 + .../providers/setup/setup.component.ts | 4 ++ ...erify-recover-delete-provider.component.ts | 2 + 73 files changed, 258 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts b/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts index 7c4e2156ffb..b8c82ac2f01 100644 --- a/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts @@ -50,6 +50,8 @@ export enum BulkCollectionsDialogResult { Canceled = "canceled", } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule, AccessSelectorModule], selector: "app-bulk-collections-dialog", diff --git a/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts b/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts index 86b83d75ca4..eafa3f4470a 100644 --- a/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts @@ -6,6 +6,8 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { CollectionDialogTabType } from "../shared/components/collection-dialog"; +// 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: "collection-access-restricted", imports: [SharedModule, ButtonModule, NoItemsModule], @@ -37,9 +39,15 @@ export class CollectionAccessRestrictedComponent { protected icon = RestrictedView; protected collectionDialogTabType = CollectionDialogTabType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() canEditCollection = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() canViewCollectionInfo = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() viewCollectionClicked = new EventEmitter<{ readonly: boolean; tab: CollectionDialogTabType; diff --git a/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts b/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts index d3893b5bd24..70a2e40001a 100644 --- a/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts @@ -9,13 +9,19 @@ import { CollectionId } from "@bitwarden/sdk-internal"; import { SharedModule } from "../../../../shared/shared.module"; import { GetCollectionNameFromIdPipe } from "../pipes"; +// 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-collection-badge", templateUrl: "collection-name-badge.component.html", imports: [SharedModule, GetCollectionNameFromIdPipe], }) export class CollectionNameBadgeComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collectionIds: CollectionId[] | string[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collections: CollectionView[]; get shownCollections(): string[] { diff --git a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts index 8f703acf9af..8a58f5b92d7 100644 --- a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts @@ -7,13 +7,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { GroupView } from "../../core"; +// 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-group-badge", templateUrl: "group-name-badge.component.html", standalone: false, }) export class GroupNameBadgeComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() selectedGroups: SelectionReadOnlyRequest[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() allGroups: GroupView[]; protected groupNames: string[] = []; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 3341a428970..01e61f0ab28 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -24,6 +24,8 @@ import { } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter-section.type"; import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type"; +// 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-organization-vault-filter", templateUrl: @@ -34,6 +36,8 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements OnInit, OnDestroy, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set organization(value: Organization) { if (value && value !== this._organization) { this._organization = value; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts index 1be16c65cb8..30582063ab2 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts @@ -37,6 +37,8 @@ import { } from "../../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { CollectionDialogTabType } from "../../shared/components/collection-dialog"; +// 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-org-vault-header", templateUrl: "./vault-header.component.html", @@ -59,36 +61,56 @@ export class VaultHeaderComponent { * Boolean to determine the loading state of the header. * Shows a loading spinner if set to true */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; /** Current active filter */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() filter: RoutedVaultFilterModel; /** The organization currently being viewed */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organization: Organization; /** Currently selected collection */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collection?: TreeNode; /** The current search text in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() searchText: string; /** Emits an event when the new item button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCipher = new EventEmitter(); /** Emits an event when the new collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCollection = new EventEmitter(); /** Emits an event when the edit collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType; readonly: boolean; }>(); /** Emits an event when the delete collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeleteCollection = new EventEmitter(); /** Emits an event when the search text changes in the header*/ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() searchTextChanged = new EventEmitter(); protected CollectionDialogTabType = CollectionDialogTabType; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index b961de9e24c..eb4e47e0ffd 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -140,6 +140,8 @@ enum AddAccessStatusType { AddAccess = 1, } +// 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-org-vault", templateUrl: "vault.component.html", @@ -207,6 +209,8 @@ export class VaultComponent implements OnInit, OnDestroy { protected selectedCollection$: Observable | undefined>; private nestedCollections$: Observable[]>; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("vaultItems", { static: false }) vaultItemsComponent: | VaultItemsComponent | undefined; diff --git a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts index cd14b73a156..d45e06ad239 100644 --- a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts +++ b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts @@ -6,17 +6,31 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.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-org-info", templateUrl: "organization-information.component.html", standalone: false, }) export class OrganizationInformationComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() nameOnly = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() createOrganization = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() isProvider = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() formGroup: UntypedFormGroup; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() changedBusinessOwned = new EventEmitter(); constructor(private accountService: AccountService) {} diff --git a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts index bc4a942301a..b463d24ea3c 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts @@ -19,18 +19,24 @@ import { DialogService } from "@bitwarden/components"; import { isEnterpriseOrgGuard } from "./is-enterprise-org.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the home screen!

", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This component can only be accessed by a enterprise organization!

", standalone: false, }) export class IsEnterpriseOrganizationComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the organization upgrade screen!

", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts index ab5fd79321a..d7c4e247d8e 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts @@ -18,18 +18,24 @@ import { DialogService } from "@bitwarden/components"; import { isPaidOrgGuard } from "./is-paid-org.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the home screen!

", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This component can only be accessed by a paid organization!

", standalone: false, }) export class PaidOrganizationOnlyComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the organization upgrade screen!

", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts index 9dc084484f3..38f13c4d781 100644 --- a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts @@ -17,18 +17,24 @@ import { UserId } from "@bitwarden/common/types/guid"; import { organizationRedirectGuard } from "./org-redirect.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the home screen!

", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is the admin console!

", standalone: false, }) export class AdminConsoleComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

This is a subroute of the admin console!

", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index b9d44c125ad..ee09143ed2f 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -36,6 +36,8 @@ import { FreeFamiliesPolicyService } from "../../../billing/services/free-famili import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; import { WebLayoutModule } from "../../../layouts/web-layout.module"; +// 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-organization-layout", templateUrl: "organization-layout.component.html", diff --git a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts index b4c5a273ac7..b4dcb9fdfac 100644 --- a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts @@ -37,6 +37,8 @@ export interface EntityEventsDialogParams { name?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule], templateUrl: "entity-events.component.html", diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index 966499c0bee..78a6d6c0dac 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -46,6 +46,8 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record = { [EventSystemUser.PublicApi]: "publicApi", }; +// 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: "events.component.html", imports: [SharedModule, HeaderModule], diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index 9b9be4e50b3..03a24703c0f 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -107,6 +107,8 @@ export const openGroupAddEditDialog = ( ); }; +// 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-group-add-edit", templateUrl: "group-add-edit.component.html", diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index 23e92056c95..d7dcb8a8aa2 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -77,6 +77,8 @@ const groupsFilter = (filter: string) => { }; }; +// 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: "groups.component.html", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts index 16543cdb58c..86d22fdf5e9 100644 --- a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts @@ -17,6 +17,8 @@ export type UserConfirmDialogData = { confirmUser: (publicKey: Uint8Array) => Promise; }; +// 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: "user-confirm.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/manage/verify-recover-delete-org.component.ts b/apps/web/src/app/admin-console/organizations/manage/verify-recover-delete-org.component.ts index f88eb82e529..001e64f48f1 100644 --- a/apps/web/src/app/admin-console/organizations/manage/verify-recover-delete-org.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/verify-recover-delete-org.component.ts @@ -12,6 +12,8 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared/shared.module"; +// 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: "verify-recover-delete-org.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/members/components/account-recovery/account-recovery-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/account-recovery/account-recovery-dialog.component.ts index 3240b8d707a..bb98225498f 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/account-recovery/account-recovery-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/account-recovery/account-recovery-dialog.component.ts @@ -61,6 +61,8 @@ export type AccountRecoveryDialogResultType = * given organization user. An admin will access this form when they want to * reset a user's password and log them out of sessions. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ standalone: true, selector: "app-account-recovery-dialog", @@ -76,6 +78,8 @@ export type AccountRecoveryDialogResultType = ], }) export class AccountRecoveryDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(InputPasswordComponent) inputPasswordComponent: InputPasswordComponent | undefined = undefined; diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 01b0d7bc380..55385ca0ce9 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -36,6 +36,8 @@ type BulkConfirmDialogParams = { users: BulkUserDetails[]; }; +// 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: "bulk-confirm-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts index 8fb60e85b08..0fd60b859f0 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts @@ -16,6 +16,8 @@ type BulkDeleteDialogParams = { users: BulkUserDetails[]; }; +// 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: "bulk-delete-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts index 9132625c587..a97d595e443 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts @@ -20,6 +20,8 @@ export type BulkEnableSecretsManagerDialogData = { users: OrganizationUserView[]; }; +// 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: `bulk-enable-sm-dialog.component.html`, standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts index 5bbc6f093f0..7c95e43c8cf 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts @@ -19,6 +19,8 @@ type BulkRemoveDialogParams = { users: BulkUserDetails[]; }; +// 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: "bulk-remove-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index ac99a9b51de..5e542de907a 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -15,6 +15,8 @@ type BulkRestoreDialogParams = { isRevoking: 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-bulk-restore-revoke", templateUrl: "bulk-restore-revoke.component.html", diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts index 078ba6c1fd1..4f2456e1dc6 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts @@ -38,6 +38,8 @@ type BulkStatusDialogData = { successfulMessage: string; }; +// 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-bulk-status", templateUrl: "bulk-status.component.html", diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index b951f73d953..9e40e5afe37 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -104,6 +104,8 @@ export enum MemberDialogResult { Restored = "restored", } +// 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: "member-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/nested-checkbox.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/nested-checkbox.component.ts index 9a2025c2b30..36dcb618989 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/nested-checkbox.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/nested-checkbox.component.ts @@ -7,6 +7,8 @@ import { Subject, takeUntil } from "rxjs"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +// 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-nested-checkbox", templateUrl: "nested-checkbox.component.html", @@ -15,7 +17,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; export class NestedCheckboxComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() parentId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() checkboxes: FormGroup>>; get parentIndeterminate() { diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 324452499dc..59c4c4898ea 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -60,6 +60,8 @@ class MembersTableDataSource extends PeopleTableDataSource protected statusType = OrganizationUserStatusType; } +// 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: "members.component.html", standalone: false, 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 9bf0ad24b1b..54d4491156c 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 @@ -78,7 +78,11 @@ export abstract class BasePolicyEditDefinition { */ @Directive() export abstract class BasePolicyEditComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policyResponse: PolicyResponse | undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policy: BasePolicyEditDefinition | undefined; /** 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 7bab6f262a6..e80796fd0af 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 @@ -37,6 +37,8 @@ import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; +// 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: "policies.component.html", imports: [SharedModule, HeaderModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts index ce62a7ff5a3..ceace60cd99 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts @@ -18,6 +18,8 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype); } } +// 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: "autotype-policy.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts index 3b4df75e555..103420fbf51 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts @@ -12,6 +12,8 @@ export class DisableSendPolicy extends BasePolicyEditDefinition { component = DisableSendPolicyComponent; } +// 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: "disable-send.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index fe3d76a0907..c1223a2004b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -26,6 +26,8 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition { component = MasterPasswordPolicyComponent; } +// 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: "master-password.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts index 94094b76f69..d832dff158a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts @@ -22,6 +22,8 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { } } +// 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: "organization-data-ownership.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts index e26d37bfdf2..e3a67362cc9 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts @@ -19,6 +19,8 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition { component = PasswordGeneratorPolicyComponent; } +// 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: "password-generator.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts index e95ef8a1422..ac768d47d6e 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts @@ -12,6 +12,8 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition { component = RemoveUnlockWithPinPolicyComponent; } +// 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: "remove-unlock-with-pin.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts index 3f28c0cb068..904c29ca70d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts @@ -19,6 +19,8 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition { } } +// 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: "require-sso.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts index fafb0b32398..bfe149048e3 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts @@ -26,6 +26,8 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition { } } +// 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: "reset-password.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts index 8f2573f0da3..554542f8a84 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts @@ -12,6 +12,8 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition { component = RestrictedItemTypesPolicyComponent; } +// 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: "restricted-item-types.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts index e581ed2f4c7..b8a59e8f8ef 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts @@ -13,6 +13,8 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition { component = SendOptionsPolicyComponent; } +// 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: "send-options.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts index ecaa86b03bc..655c5f20610 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts @@ -12,6 +12,8 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition { component = SingleOrgPolicyComponent; } +// 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: "single-org.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts index 13b7660c4e7..62f3d1f3466 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts @@ -12,6 +12,8 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition { component = TwoFactorAuthenticationPolicyComponent; } +// 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: "two-factor-authentication.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts index 2234d5c7437..627f5762eda 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts @@ -34,6 +34,8 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti } } +// 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: "vnext-organization-data-ownership.component.html", imports: [SharedModule], @@ -50,6 +52,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent super(); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("dialog", { static: true }) warningContent!: TemplateRef; override async confirm(): Promise { 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 d98b5d4809b..98b6d1c6bee 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 @@ -45,11 +45,15 @@ export type PolicyEditDialogData = { export type PolicyEditDialogResult = "saved"; +// 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: "policy-edit-dialog.component.html", imports: [SharedModule], }) export class PolicyEditDialogComponent implements AfterViewInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("policyForm", { read: ViewContainerRef, static: true }) policyFormRef: ViewContainerRef | undefined; diff --git a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts index 52cb24c90d1..6043bfd3193 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts @@ -14,6 +14,8 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ReportVariant, reports, ReportType, ReportEntry } from "../../../dirt/reports"; +// 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-org-reports-home", templateUrl: "reports-home.component.html", diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index 21424e86521..68b220aeac0 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -38,6 +38,8 @@ import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.compone import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./components"; +// 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-org-account", templateUrl: "account.component.html", diff --git a/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts b/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts index 1b41dc31a62..8cf1530cb7d 100644 --- a/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts @@ -78,6 +78,8 @@ export enum DeleteOrganizationDialogResult { Canceled = "canceled", } +// 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-delete-organization", imports: [SharedModule, UserVerificationModule], diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 3151e0a702f..46e39a112bf 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -26,6 +26,8 @@ import { TwoFactorSetupDuoComponent } from "../../../auth/settings/two-factor/tw import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor/two-factor-setup.component"; import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor/two-factor-verify.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({ selector: "app-two-factor-setup", templateUrl: "../../../auth/settings/two-factor/two-factor-setup.component.html", diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts index 43843314ce5..89ecfd07174 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts @@ -45,6 +45,8 @@ export enum PermissionMode { Edit = "edit", } +// 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: "bit-access-selector", templateUrl: "access-selector.component.html", @@ -139,6 +141,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * List of all selectable items that. Sorted internally. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get items(): AccessItemView[] { return this.selectionList.allItems; @@ -160,6 +164,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * Permission mode that controls if the permission form controls and column should be present. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get permissionMode(): PermissionMode { return this._permissionMode; @@ -175,41 +181,64 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * Column header for the selected items table */ - @Input() columnHeader: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + columnHeader: string; /** * Label used for the ng selector */ - @Input() selectorLabelText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + selectorLabelText: string; /** * Helper text displayed under the ng selector */ - @Input() selectorHelpText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + selectorHelpText: string; /** * Text that is shown in the table when no items are selected */ - @Input() emptySelectionText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + emptySelectionText: string; /** * Flag for if the member roles column should be present */ - @Input() showMemberRoles: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + showMemberRoles: boolean; /** * Flag for if the group column should be present */ - @Input() showGroupColumn: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + showGroupColumn: boolean; /** * Hide the multi-select so that new items cannot be added */ - @Input() hideMultiSelect = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + hideMultiSelect = false; /** * The initial permission that will be selected in the dialog, defaults to View. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() protected initialPermission: CollectionPermission = CollectionPermission.View; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index ea1a47d85cc..7b189270e1b 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -116,6 +116,8 @@ export enum CollectionDialogAction { Upgrade = "upgrade", } +// 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: "collection-dialog.component.html", imports: [SharedModule, AccessSelectorModule, SelectModule], diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index c4fe0350006..c34073b2a04 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -18,6 +18,8 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component"; * "Bitwarden allows all members of Enterprise Organizations to redeem a complimentary Families Plan with their * personal email address." - https://bitwarden.com/learning/free-families-plan-for-enterprise/ */ +// 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: "accept-family-sponsorship.component.html", imports: [CommonModule, I18nPipe, IconModule], diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts index 30c0ba159c1..3c400decd52 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -28,11 +28,15 @@ import { openDeleteOrganizationDialog, } from "../settings/components"; +// 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: "families-for-enterprise-setup.component.html", imports: [SharedModule, OrganizationPlansComponent], }) export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(OrganizationPlansComponent, { static: false }) set organizationPlansComponent(value: OrganizationPlansComponent) { if (!value) { diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.ts b/apps/web/src/app/admin-console/settings/create-organization.component.ts index f87e9ec5b72..bdf450fb265 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.ts +++ b/apps/web/src/app/admin-console/settings/create-organization.component.ts @@ -11,6 +11,8 @@ import { OrganizationPlansComponent } from "../../billing"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// 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: "create-organization.component.html", imports: [SharedModule, OrganizationPlansComponent, HeaderModule], diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts index 258a112e234..3c2adb46193 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts @@ -24,6 +24,8 @@ import { KeyService } from "@bitwarden/key-management"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; +// 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-org-device-approvals", templateUrl: "./device-approvals.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.ts index 970a476df22..e3e5a927369 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.ts @@ -22,6 +22,8 @@ export interface DomainAddEditDialogData { existingDomainNames: Array; } +// 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: "domain-add-edit-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts index 3bc916d3fc5..bfe382f930e 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts @@ -33,6 +33,8 @@ import { DomainAddEditDialogData, } from "./domain-add-edit-dialog/domain-add-edit-dialog.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({ selector: "app-org-manage-domain-verification", templateUrl: "domain-verification.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts index de870cdbdcb..9e7f35a8475 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts @@ -21,6 +21,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +// 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-org-manage-scim", templateUrl: "scim.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts index 17efc017136..c32eb3d935b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts @@ -21,6 +21,8 @@ export class ActivateAutofillPolicy extends BasePolicyEditDefinition { } } +// 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: "activate-autofill.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts index 7dadc04c6f4..85110a5af21 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts @@ -17,6 +17,8 @@ export class AutomaticAppLoginPolicy extends BasePolicyEditDefinition { component = AutomaticAppLoginPolicyComponent; } +// 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: "automatic-app-login.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts index d93fb50b0e2..17e8eb055b5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts @@ -14,6 +14,8 @@ export class DisablePersonalVaultExportPolicy extends BasePolicyEditDefinition { component = DisablePersonalVaultExportPolicyComponent; } +// 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: "disable-personal-vault-export.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts index 160ce9aeb20..277388e2883 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts @@ -20,6 +20,8 @@ export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition { component = MaximumVaultTimeoutPolicyComponent; } +// 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: "maximum-vault-timeout.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts index 9f28ba87186..b673dfd1b14 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts @@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.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({ selector: "app-accept-provider", templateUrl: "accept-provider.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts index e21837f7226..635aaf16b3f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts @@ -33,6 +33,8 @@ export enum AddEditMemberDialogResultType { Saved = "saved", } +// 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: "add-edit-member-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 8bbc299269d..dd54b842062 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -26,6 +26,8 @@ type BulkConfirmDialogParams = { users: BulkUserDetails[]; }; +// 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: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index e000d918414..29b50f71c1b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -16,6 +16,8 @@ type BulkRemoveDialogParams = { users: BulkUserDetails[]; }; +// 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: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.ts index 43fc958585a..3d00d897175 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.ts @@ -20,6 +20,8 @@ import { BaseEventsComponent } from "@bitwarden/web-vault/app/admin-console/comm import { EventService } from "@bitwarden/web-vault/app/core"; import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export"; +// 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: "provider-events", templateUrl: "events.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index e86956dec93..b1cd52cf8a6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -46,6 +46,8 @@ class MembersTableDataSource extends PeopleTableDataSource { protected statusType = ProviderUserStatusType; } +// 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: "members.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index da82742ddd5..2e0cf2163a4 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -23,6 +23,8 @@ import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.mod import { ProviderWarningsService } from "../../billing/providers/warnings/services"; +// 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: "providers-layout", templateUrl: "providers-layout.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.ts index d13ac863437..aa79ec7e29e 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.ts @@ -10,6 +10,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +// 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-providers", templateUrl: "providers.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts index 12dada12aa9..705069dc697 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts @@ -17,6 +17,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, ToastService } from "@bitwarden/components"; +// 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: "provider-account", templateUrl: "account.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts index 02ca72fa9b8..fa75f4b7635 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts @@ -4,6 +4,8 @@ import { Params } from "@angular/router"; import { BitwardenLogo } from "@bitwarden/assets/svg"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.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({ selector: "app-setup-provider", templateUrl: "setup-provider.component.html", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 0fa69c7a0e6..87c48608b10 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -20,12 +20,16 @@ import { getBillingAddressFromForm, } from "@bitwarden/web-vault/app/billing/payment/components"; +// 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: "provider-setup", templateUrl: "setup.component.html", standalone: false, }) export class SetupComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; loading = true; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts index 5c0d0982fb5..f1be766a9a2 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts @@ -10,6 +10,8 @@ import { ProviderVerifyRecoverDeleteRequest } from "@bitwarden/common/admin-cons import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +// 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-verify-recover-delete-provider", templateUrl: "verify-recover-delete-provider.component.html", From bb07365ea5b9bf2c70649109be5157df220a5d47 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:34:16 -0500 Subject: [PATCH 21/35] await call that creates Customer in case we're upgrading from free (#16999) --- .../billing/organizations/change-plan-dialog.component.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 9d093ec4514..c2c819ddf4d 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -842,10 +842,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; - await Promise.all([ - this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null), - this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress), - ]); + // These need to be synchronous so one of them can create the Customer in the case we're upgrading from Free. + await this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress); + await this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null); } // Backfill pub/priv key if necessary From d91fdad0118d66a3c54ea0839f98b59a04b09f88 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Thu, 23 Oct 2025 11:54:20 -0400 Subject: [PATCH 22/35] [PM-24650] Resolve sign in button disappearing from ADP login form (#16901) * ensure autofillInsertActions execution order is preserved * don't fill a field if it already has the value that is going to be filled * update tests --- .../insert-autofill-content.service.spec.ts | 54 ++++++++++++++----- .../insert-autofill-content.service.ts | 9 +++- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 9edcdbb3a95..07fdfb9db79 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -103,7 +103,7 @@ describe("InsertAutofillContentService", () => { delay_between_operations: 20, }, metadata: {}, - autosubmit: null, + autosubmit: [], savedUrls: ["https://bitwarden.com"], untrustedIframe: false, itemType: "login", @@ -218,28 +218,21 @@ describe("InsertAutofillContentService", () => { await insertAutofillContentService.fillForm(fillScript); - expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); - expect( - insertAutofillContentService["userCancelledUntrustedIframeAutofill"], - ).toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenCalledTimes(3); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 1, fillScript.script[0], 0, - fillScript.script, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 2, fillScript.script[1], 1, - fillScript.script, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 3, fillScript.script[2], 2, - fillScript.script, ); }); }); @@ -623,14 +616,12 @@ describe("InsertAutofillContentService", () => { }); }); - it("will set the `value` attribute of any passed input or textarea elements", () => { - document.body.innerHTML = ``; + it("will set the `value` attribute of any passed input or textarea elements if the value differs", () => { + document.body.innerHTML = ``; const value1 = "test"; const value2 = "test2"; const textInputElement = document.getElementById("username") as HTMLInputElement; - textInputElement.value = value1; const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; - textareaElement.value = value2; jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); insertAutofillContentService["insertValueIntoField"](textInputElement, value1); @@ -647,6 +638,45 @@ describe("InsertAutofillContentService", () => { insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], ).toHaveBeenCalledWith(textareaElement, expect.any(Function)); }); + + it("will NOT set the `value` attribute of any passed input or textarea elements if they already have values matching the passed value", () => { + document.body.innerHTML = ``; + const value1 = "test"; + const value2 = "test2"; + const textInputElement = document.getElementById("username") as HTMLInputElement; + textInputElement.value = value1; + const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; + textareaElement.value = value2; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](textInputElement, value1); + + expect(textInputElement.value).toBe(value1); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + + insertAutofillContentService["insertValueIntoField"](textareaElement, value2); + + expect(textareaElement.value).toBe(value2); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + }); + + it("skips filling when the field already has the target value", () => { + const value = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + expect(element.value).toBe(value); + }); }); describe("handleInsertValueAndTriggerSimulatedEvents", () => { diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 6034563a947..9ddbcdc005d 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -49,8 +49,9 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf return; } - const fillActionPromises = fillScript.script.map(this.runFillScriptAction); - await Promise.all(fillActionPromises); + for (let index = 0; index < fillScript.script.length; index++) { + await this.runFillScriptAction(fillScript.script[index], index); + } } /** @@ -189,10 +190,14 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf const elementCanBeReadonly = elementIsInputElement(element) || elementIsTextAreaElement(element); const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element); + const elementValue = (element as HTMLInputElement)?.value || element?.innerText || ""; + + const elementAlreadyHasTheValue = !!(elementValue?.length && elementValue === value); if ( !element || !value || + elementAlreadyHasTheValue || (elementCanBeReadonly && element.readOnly) || (elementCanBeFilled && element.disabled) ) { From 660e452ba1de0c0c6dcea5665b859b690c79bd7e Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:59:57 +0100 Subject: [PATCH 23/35] [PM-25858]Organization warnings endpoint should not be called from self-hosted instances (#16781) * ensure that getWarnings from server is not called for selfhost * Refactor the code * move the selfhost check to getWarning message * Fix the failing test --- .../organization-warnings.service.spec.ts | 58 +++++++++++++++++++ .../services/organization-warnings.service.ts | 12 +++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts index 8c2a7634264..9466e813e4d 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -16,6 +16,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogRef, DialogService } from "@bitwarden/components"; import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { @@ -37,6 +38,7 @@ describe("OrganizationWarningsService", () => { let i18nService: MockProxy; let organizationApiService: MockProxy; let organizationBillingClient: MockProxy; + let platformUtilsService: MockProxy; let router: MockProxy; const organization = { @@ -58,10 +60,13 @@ describe("OrganizationWarningsService", () => { i18nService = mock(); organizationApiService = mock(); organizationBillingClient = mock(); + platformUtilsService = mock(); router = mock(); (openChangePlanDialog as jest.Mock).mockReset(); + platformUtilsService.isSelfHost.mockReturnValue(false); + i18nService.t.mockImplementation((key: string, ...args: any[]) => { switch (key) { case "freeTrialEndPromptCount": @@ -94,6 +99,7 @@ describe("OrganizationWarningsService", () => { { provide: I18nService, useValue: i18nService }, { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, { provide: OrganizationBillingClient, useValue: organizationBillingClient }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: Router, useValue: router }, ], }); @@ -111,6 +117,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return warning with count message when remaining trial days >= 2", (done) => { const warning = { remainingTrialDays: 5 }; organizationBillingClient.getWarnings.mockResolvedValue({ @@ -206,6 +222,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return upcoming warning with correct type and message", (done) => { const renewalDate = new Date(2024, 11, 31); const warning = { @@ -298,6 +324,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return tax_id_missing type when tax ID is missing", (done) => { const warning = { type: TaxIdWarningTypes.Missing }; organizationBillingClient.getWarnings.mockResolvedValue({ @@ -427,6 +463,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should not show dialog when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should show contact provider dialog for contact_provider resolution", (done) => { const warning = { resolution: "contact_provider" }; organizationBillingClient.getWarnings.mockResolvedValue({ @@ -570,6 +616,18 @@ describe("OrganizationWarningsService", () => { }); }); + it("should not show dialog when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).not.toHaveBeenCalled(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }, + }); + }); + it("should open trial payment dialog when free trial warning exists", (done) => { const warning = { remainingTrialDays: 2 }; const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 8bec7acffe1..a34533bcada 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -8,6 +8,7 @@ import { map, merge, Observable, + of, Subject, switchMap, tap, @@ -17,6 +18,7 @@ import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -56,6 +58,7 @@ export class OrganizationWarningsService { private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, private organizationBillingClient: OrganizationBillingClient, + private platformUtilsService: PlatformUtilsService, private router: Router, ) {} @@ -281,12 +284,17 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.readThroughWarnings$(organization, bypassCache).pipe( + ): Observable => { + if (this.platformUtilsService.isSelfHost()) { + return of(null); + } + + return this.readThroughWarnings$(organization, bypassCache).pipe( map((response) => { const value = extract(response); return value ? value : null; }), take(1), ); + }; } From 2c13236550652963e13a26c2102da0cd14b1e3ec Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:42:48 -0700 Subject: [PATCH 24/35] Add desktop autotype unittests for windows (#16710) * Add desktop autotype unittests for windows * lint * fix TODO comment * feedback coltonhurst: rename trait --- apps/desktop/desktop_native/Cargo.lock | 112 +++++++ .../desktop_native/autotype/Cargo.toml | 2 + .../desktop_native/autotype/src/lib.rs | 7 +- .../autotype/src/windows/mod.rs | 41 +++ .../src/{windows.rs => windows/type_input.rs} | 250 +++++++-------- .../autotype/src/windows/window_title.rs | 298 ++++++++++++++++++ 6 files changed, 565 insertions(+), 145 deletions(-) create mode 100644 apps/desktop/desktop_native/autotype/src/windows/mod.rs rename apps/desktop/desktop_native/autotype/src/{windows.rs => windows/type_input.rs} (57%) create mode 100644 apps/desktop/desktop_native/autotype/src/windows/window_title.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 3df6b41734b..5dec59f0f12 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -343,6 +343,8 @@ name = "autotype" version = "0.0.0" dependencies = [ "anyhow", + "mockall", + "serial_test", "tracing", "windows 0.61.1", "windows-core 0.61.0", @@ -1070,6 +1072,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1288,6 +1296,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fs-err" version = "2.11.0" @@ -1943,6 +1957,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "napi" version = "2.16.17" @@ -2575,6 +2615,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2877,6 +2943,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2920,6 +2995,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "sec1" version = "0.7.3" @@ -3024,6 +3105,31 @@ dependencies = [ "syn", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3263,6 +3369,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "textwrap" version = "0.16.2" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 3d1e74254ce..267074d0bc8 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -9,6 +9,8 @@ publish.workspace = true anyhow = { workspace = true } [target.'cfg(windows)'.dependencies] +mockall = "=0.13.1" +serial_test = "=3.2.0" tracing.workspace = true windows = { workspace = true, features = [ "Win32_UI_Input_KeyboardAndMouse", diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 92996996434..c87fea23b60 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -2,7 +2,7 @@ use anyhow::Result; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] mod windowing; /// Gets the title bar string for the foreground window. @@ -20,12 +20,13 @@ pub fn get_foreground_window_title() -> Result { /// /// # Arguments /// -/// * `input` must be an array of utf-16 encoded characters to insert. +/// * `input` an array of utf-16 encoded characters to insert. +/// * `keyboard_shortcut` a vector of valid shortcut keys: Control, Alt, Super, Shift, letters a - Z /// /// # Errors /// /// This function returns an `anyhow::Error` if there is any -/// issue obtaining the window title. Detailed reasons will +/// issue in typing the input. Detailed reasons will /// vary based on platform implementation. pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { windowing::type_input(input, keyboard_shortcut) diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs new file mode 100644 index 00000000000..3ea63b2b8f4 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use tracing::debug; +use windows::Win32::Foundation::{GetLastError, SetLastError, WIN32_ERROR}; + +mod type_input; +mod window_title; + +/// The error code from Win32 API that represents a non-error. +const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); + +/// `ErrorOperations` provides an interface to the Win32 API for dealing with +/// win32 errors. +#[cfg_attr(test, mockall::automock)] +trait ErrorOperations { + /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setlasterror + fn set_last_error(err: u32) { + debug!(err, "Calling SetLastError"); + unsafe { + SetLastError(WIN32_ERROR(err)); + } + } + + /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-getlasterror + fn get_last_error() -> WIN32_ERROR { + let last_err = unsafe { GetLastError() }; + debug!("GetLastError(): {}", last_err.to_hresult().message()); + last_err + } +} + +/// Default implementation for Win32 API errors. +struct Win32ErrorOperations; +impl ErrorOperations for Win32ErrorOperations {} + +pub fn get_foreground_window_title() -> Result { + window_title::get_foreground_window_title() +} + +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + type_input::type_input(input, keyboard_shortcut) +} diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs similarity index 57% rename from apps/desktop/desktop_native/autotype/src/windows.rs rename to apps/desktop/desktop_native/autotype/src/windows/type_input.rs index 01270e7971d..b757cf7752f 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -1,136 +1,42 @@ -use std::{ffi::OsString, os::windows::ffi::OsStringExt}; - use anyhow::{anyhow, Result}; -use tracing::{debug, error, warn}; -use windows::Win32::{ - Foundation::{GetLastError, SetLastError, HWND, WIN32_ERROR}, - UI::{ - Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, - KEYEVENTF_UNICODE, VIRTUAL_KEY, - }, - WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW}, - }, +use tracing::{debug, error}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, + VIRTUAL_KEY, }; -const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); +use super::{ErrorOperations, Win32ErrorOperations}; -fn clear_last_error() { - debug!("Clearing last error with SetLastError."); - unsafe { - SetLastError(WIN32_ERROR(0)); +/// `InputOperations` provides an interface to Window32 API for +/// working with inputs. +#[cfg_attr(test, mockall::automock)] +trait InputOperations { + /// Attempts to type the provided input wherever the user's cursor is. + /// + /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput + fn send_input(inputs: &[INPUT]) -> u32; +} + +struct Win32InputOperations; + +impl InputOperations for Win32InputOperations { + fn send_input(inputs: &[INPUT]) -> u32 { + const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::() as i32; + let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) }; + + debug!(insert_count, "SendInput() called."); + + insert_count } } -fn get_last_error() -> WIN32_ERROR { - let last_err = unsafe { GetLastError() }; - debug!("GetLastError(): {}", last_err.to_hresult().message()); - last_err -} - -// The handle should be validated before any unsafe calls referencing it. -fn validate_window_handle(handle: &HWND) -> Result<()> { - if handle.is_invalid() { - error!("Window handle is invalid."); - return Err(anyhow!("Window handle is invalid.")); - } - Ok(()) -} - -// ---------- Window title -------------- - -/// Gets the title bar string for the foreground window. -pub fn get_foreground_window_title() -> Result { - // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow - let window_handle = unsafe { GetForegroundWindow() }; - - debug!("GetForegroundWindow() called."); - - validate_window_handle(&window_handle)?; - - get_window_title(&window_handle) -} - -/// Gets the length of the window title bar text. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw -fn get_window_title_length(window_handle: &HWND) -> Result { - // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. - clear_last_error(); - - validate_window_handle(window_handle)?; - - let length = unsafe { GetWindowTextLengthW(*window_handle) }; - - let length = usize::try_from(length)?; - - debug!(length, "window text length retrieved from handle."); - - if length == 0 { - // attempt to retreive win32 error - let last_err = get_last_error(); - if last_err != WIN32_SUCCESS { - let last_err = last_err.to_hresult().message(); - error!(last_err, "Error getting window text length."); - return Err(anyhow!("Error getting window text length: {last_err}")); - } - } - - Ok(length) -} - -/// Gets the window title bar title. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw -fn get_window_title(window_handle: &HWND) -> Result { - let expected_window_title_length = get_window_title_length(window_handle)?; - - // This isn't considered an error by the windows API, but in practice it means we can't - // match against the title so we'll stop here. - // The upstream will make a contains comparison on what we return, so an empty string - // will not result on a match. - if expected_window_title_length == 0 { - warn!("Window title length is zero."); - return Ok(String::from("")); - } - - let mut buffer: Vec = vec![0; expected_window_title_length + 1]; // add extra space for the null character - - validate_window_handle(window_handle)?; - - let actual_window_title_length = unsafe { GetWindowTextW(*window_handle, &mut buffer) }; - - debug!(actual_window_title_length, "window title retrieved."); - - if actual_window_title_length == 0 { - // attempt to retreive win32 error - let last_err = get_last_error(); - if last_err != WIN32_SUCCESS { - let last_err = last_err.to_hresult().message(); - error!(last_err, "Error retrieving window title."); - return Err(anyhow!("Error retrieving window title. {last_err}")); - } - // in practice, we should not get to the below code, since we asserted the len > 0 - // above. but it is an extra protection in case the windows API didn't set an error. - warn!(expected_window_title_length, "No window title retrieved."); - } - - let window_title = OsString::from_wide(&buffer); - - Ok(window_title.to_string_lossy().into_owned()) -} - -// ---------- Type Input -------------- - /// Attempts to type the input text wherever the user's cursor is. /// /// `input` must be a vector of utf-16 encoded characters to insert. /// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { - const TAB_KEY: u8 = 9; - +pub(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { // the length of this vec is always shortcut keys to release + (2x length of input chars) let mut keyboard_inputs: Vec = Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); @@ -142,25 +48,31 @@ pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?); } - // Add key "down" and "up" inputs for the input - // (currently in this form: {username}/t{password}) + add_input(&input, &mut keyboard_inputs); + + send_input::(keyboard_inputs) +} + +// Add key "down" and "up" inputs for the input +// (currently in this form: {username}/t{password}) +fn add_input(input: &[u16], keyboard_inputs: &mut Vec) { + const TAB_KEY: u8 = 9; + for i in input { - let next_down_input = if i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Down, i as u8) + let next_down_input = if *i == TAB_KEY.into() { + build_virtual_key_input(InputKeyPress::Down, *i as u8) } else { - build_unicode_input(InputKeyPress::Down, i) + build_unicode_input(InputKeyPress::Down, *i) }; - let next_up_input = if i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Up, i as u8) + let next_up_input = if *i == TAB_KEY.into() { + build_virtual_key_input(InputKeyPress::Up, *i as u8) } else { - build_unicode_input(InputKeyPress::Up, i) + build_unicode_input(InputKeyPress::Up, *i) }; keyboard_inputs.push(next_down_input); keyboard_inputs.push(next_up_input); } - - send_input(keyboard_inputs) } /// Converts a valid shortcut key to an "up" keyboard input. @@ -294,21 +206,20 @@ fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT { } } -/// Attempts to type the provided input wherever the user's cursor is. -/// -/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -fn send_input(inputs: Vec) -> Result<()> { - let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; - - debug!("SendInput() called."); +fn send_input(inputs: Vec) -> Result<()> +where + I: InputOperations, + E: ErrorOperations, +{ + let insert_count = I::send_input(&inputs); if insert_count == 0 { - let last_err = get_last_error().to_hresult().message(); + let last_err = E::get_last_error().to_hresult().message(); error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread."); return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}")); } else if insert_count != inputs.len() as u32 { - let last_err = get_last_error().to_hresult().message(); + let last_err = E::get_last_error().to_hresult().message(); error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err, "SendInput sent does not match expected." ); @@ -318,17 +229,23 @@ fn send_input(inputs: Vec) -> Result<()> { )); } - debug!(insert_count, "Autotype sent input."); - Ok(()) } #[cfg(test)] mod tests { + //! For the mocking of the traits that are static methods, we need to use the `serial_test` crate + //! in order to mock those, since the mock expectations set have to be global in absence of a `self`. + //! More info: https://docs.rs/mockall/latest/mockall/#static-methods + use super::*; + use crate::windowing::MockErrorOperations; + use serial_test::serial; + use windows::Win32::Foundation::WIN32_ERROR; + #[test] - fn get_alphabetic_hot_key_happy() { + fn get_alphabetic_hot_key_succeeds() { for c in ('a'..='z').chain('A'..='Z') { let letter = c.to_string(); let converted = get_alphabetic_hotkey(letter).unwrap(); @@ -349,4 +266,53 @@ mod tests { let letter = String::from("}"); get_alphabetic_hotkey(letter).unwrap(); } + + #[test] + #[serial] + fn send_input_succeeds() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 1); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } + + #[test] + #[serial] + #[should_panic( + expected = "SendInput sent 0 inputs. Input was blocked by another thread. GetLastError:" + )] + fn send_input_fails_sent_zero() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 0); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } + + #[test] + #[serial] + #[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")] + fn send_input_fails_sent_mismatch() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 2); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } } diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs new file mode 100644 index 00000000000..58f06eb54c1 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -0,0 +1,298 @@ +use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + +use anyhow::{anyhow, Result}; +use tracing::{debug, error, warn}; +use windows::Win32::{ + Foundation::HWND, + UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW}, +}; + +use super::{ErrorOperations, Win32ErrorOperations, WIN32_SUCCESS}; + +#[cfg_attr(test, mockall::automock)] +trait WindowHandleOperations { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw + fn get_window_text_length_w(&self) -> Result; + + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw + fn get_window_text_w(&self, buffer: &mut Vec) -> Result; +} + +/// `WindowHandle` provides a light wrapper over the `HWND` (which is just a void *). +/// The raw pointer can become invalid during runtime so it's validity must be checked +/// before usage. +struct WindowHandle { + handle: HWND, +} + +impl WindowHandle { + /// Create a new `WindowHandle` + fn new(handle: HWND) -> Self { + Self { handle } + } + + /// Assert that the raw pointer is valid. + fn validate(&self) -> Result<()> { + if self.handle.is_invalid() { + error!("Window handle is invalid."); + return Err(anyhow!("Window handle is invalid.")); + } + Ok(()) + } +} + +impl WindowHandleOperations for WindowHandle { + fn get_window_text_length_w(&self) -> Result { + self.validate()?; + let length = unsafe { GetWindowTextLengthW(self.handle) }; + Ok(length) + } + + fn get_window_text_w(&self, buffer: &mut Vec) -> Result { + self.validate()?; + let len_written = unsafe { GetWindowTextW(self.handle, buffer) }; + Ok(len_written) + } +} + +/// Gets the title bar string for the foreground window. +pub(super) fn get_foreground_window_title() -> Result { + let window_handle = get_foreground_window_handle()?; + + let expected_window_title_length = + get_window_title_length::(&window_handle)?; + + get_window_title::( + &window_handle, + expected_window_title_length, + ) +} + +/// Retrieves the foreground window handle and validates it. +fn get_foreground_window_handle() -> Result { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow + let handle = unsafe { GetForegroundWindow() }; + + debug!("GetForegroundWindow() called."); + + let window_handle = WindowHandle::new(handle); + window_handle.validate()?; + + Ok(window_handle) +} + +/// # Returns +/// +/// The length of the window title. +/// +/// # Errors +/// +/// - If the length zero and GetLastError() != 0, return the GetLastError() message. +fn get_window_title_length(window_handle: &H) -> Result +where + H: WindowHandleOperations, + E: ErrorOperations, +{ + // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. + E::set_last_error(0); + + let length = window_handle.get_window_text_length_w()?; + + let length = usize::try_from(length)?; + + debug!(length, "window text length retrieved from handle."); + + if length == 0 { + // attempt to retreive win32 error + let last_err = E::get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error getting window text length."); + return Err(anyhow!("Error getting window text length: {last_err}")); + } + } + + Ok(length) +} + +/// Gets the window title bar title using the expected length to determine size of buffer +/// to store it. +/// +/// # Returns +/// +/// If the `expected_title_length` is zero, return an Ok result containing empty string. It +/// Isn't considered an error by the Win32 API. +/// +/// Otherwise, return the retrieved window title string. +/// +/// # Errors +/// +/// - If the actual window title length (what the win32 API declares was written into the +/// buffer), is length zero and GetLastError() != 0 , return the GetLastError() message. +fn get_window_title(window_handle: &H, expected_title_length: usize) -> Result +where + H: WindowHandleOperations, + E: ErrorOperations, +{ + if expected_title_length == 0 { + // This isn't considered an error by the windows API, but in practice it means we can't + // match against the title so we'll stop here. + // The upstream will make a contains comparison on what we return, so an empty string + // will not result on a match. + warn!("Window title length is zero."); + return Ok(String::from("")); + } + + let mut buffer: Vec = vec![0; expected_title_length + 1]; // add extra space for the null character + + let actual_window_title_length = window_handle.get_window_text_w(&mut buffer)?; + + debug!(actual_window_title_length, "window title retrieved."); + + if actual_window_title_length == 0 { + // attempt to retreive win32 error + let last_err = E::get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error retrieving window title."); + return Err(anyhow!("Error retrieving window title: {last_err}")); + } + // in practice, we should not get to the below code, since we asserted the len > 0 + // above. but it is an extra protection in case the windows API didn't set an error. + warn!(expected_title_length, "No window title retrieved."); + } + + let window_title = OsString::from_wide(&buffer); + + Ok(window_title.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + //! For the mocking of the traits that are static methods, we need to use the `serial_test` crate + //! in order to mock those, since the mock expectations set have to be global in absence of a `self`. + //! More info: https://docs.rs/mockall/latest/mockall/#static-methods + + use super::*; + + use crate::windowing::MockErrorOperations; + use mockall::predicate; + use serial_test::serial; + use windows::Win32::Foundation::WIN32_ERROR; + + #[test] + #[serial] + fn get_window_title_length_can_be_zero() { + let mut mock_handle = MockWindowHandleOperations::new(); + + let ctxse = MockErrorOperations::set_last_error_context(); + ctxse + .expect() + .once() + .with(predicate::eq(0)) + .returning(|_| {}); + + mock_handle + .expect_get_window_text_length_w() + .once() + .returning(|| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(0)); + + let len = get_window_title_length::( + &mock_handle, + ) + .unwrap(); + + assert_eq!(len, 0); + } + + #[test] + #[serial] + #[should_panic(expected = "Error getting window text length:")] + fn get_window_title_length_fails() { + let mut mock_handle = MockWindowHandleOperations::new(); + + let ctxse = MockErrorOperations::set_last_error_context(); + ctxse.expect().with(predicate::eq(0)).returning(|_| {}); + + mock_handle + .expect_get_window_text_length_w() + .once() + .returning(|| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + get_window_title_length::(&mock_handle) + .unwrap(); + } + + #[test] + fn get_window_title_succeeds() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|buffer| { + buffer.fill_with(|| 42); // because why not + Ok(42) + }); + + let title = + get_window_title::(&mock_handle, 42) + .unwrap(); + + assert_eq!(title.len(), 43); // That extra slot in the buffer for null char + + assert_eq!(title, "*******************************************"); + } + + #[test] + fn get_window_title_returns_empty_string() { + let mock_handle = MockWindowHandleOperations::new(); + + let title = + get_window_title::(&mock_handle, 0) + .unwrap(); + + assert_eq!(title, ""); + } + + #[test] + #[serial] + #[should_panic(expected = "Error retrieving window title:")] + fn get_window_title_fails_with_last_error() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|_| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + get_window_title::(&mock_handle, 42) + .unwrap(); + } + + #[test] + #[serial] + fn get_window_title_doesnt_fail_but_reads_zero() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|_| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(0)); + + get_window_title::(&mock_handle, 42) + .unwrap(); + } +} From 81e9015b5b7ecbc38b50bbf3a0fb3420ea5c402d Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:54:52 -0400 Subject: [PATCH 25/35] Adding include my items to the services and reports (#16987) --- .../exposed-passwords-report.component.ts | 2 +- .../reused-passwords-report.component.ts | 2 +- .../unsecured-websites-report.component.ts | 2 +- .../weak-passwords-report.component.ts | 2 +- libs/common/src/abstractions/api.service.ts | 5 ++++- libs/common/src/services/api.service.ts | 17 +++++++++-------- .../src/vault/abstractions/cipher.service.ts | 5 ++++- .../common/src/vault/services/cipher.service.ts | 10 ++++++++-- 8 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index b88987e1d25..e7392ad609a 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -86,7 +86,7 @@ export class ExposedPasswordsReportComponent } getAllCiphers(): Promise { - return this.cipherService.getAllFromApiForOrganization(this.organization.id); + return this.cipherService.getAllFromApiForOrganization(this.organization.id, true); } canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts index 7fcf3562437..5c48919510e 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts @@ -84,7 +84,7 @@ export class ReusedPasswordsReportComponent } getAllCiphers(): Promise { - return this.cipherService.getAllFromApiForOrganization(this.organization.id); + return this.cipherService.getAllFromApiForOrganization(this.organization.id, true); } canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts index 2e916da0294..dad9688f105 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts @@ -89,7 +89,7 @@ export class UnsecuredWebsitesReportComponent } getAllCiphers(): Promise { - return this.cipherService.getAllFromApiForOrganization(this.organization.id); + return this.cipherService.getAllFromApiForOrganization(this.organization.id, true); } protected canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts index 80be66e9ad2..67ca5081b6b 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts @@ -88,7 +88,7 @@ export class WeakPasswordsReportComponent } getAllCiphers(): Promise { - return this.cipherService.getAllFromApiForOrganization(this.organization.id); + return this.cipherService.getAllFromApiForOrganization(this.organization.id, true); } canManageCipher(c: CipherView): boolean { diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 93e47a6d9a8..761038c2e46 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -194,7 +194,10 @@ export abstract class ApiService { cipherId: string, attachmentId: string, ): Promise; - abstract getCiphersOrganization(organizationId: string): Promise>; + abstract getCiphersOrganization( + organizationId: string, + includeMemberItems?: boolean, + ): Promise>; abstract postCipher(request: CipherRequest): Promise; abstract postCipherCreate(request: CipherCreateRequest): Promise; abstract postCipherAdmin(request: CipherCreateRequest): Promise; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 3b4fef9c5c4..b7f5f0ed001 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -408,14 +408,15 @@ export class ApiService implements ApiServiceAbstraction { return new CipherResponse(r); } - async getCiphersOrganization(organizationId: string): Promise> { - const r = await this.send( - "GET", - "/ciphers/organization-details?organizationId=" + organizationId, - null, - true, - true, - ); + async getCiphersOrganization( + organizationId: string, + includeMemberItems?: boolean, + ): Promise> { + let url = "/ciphers/organization-details?organizationId=" + organizationId; + if (includeMemberItems) { + url += `&includeMemberItems=${includeMemberItems}`; + } + const r = await this.send("GET", url, null, true, true); return new ListResponse(r, CipherResponse); } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 7971b6d4658..9aefd960b2f 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -76,7 +76,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract getAllFromApiForOrganization(organizationId: string): Promise; + abstract getAllFromApiForOrganization( + organizationId: string, + includeMemberItems?: boolean, + ): Promise; /** * Gets ciphers belonging to the specified organization that the user has explicit collection level access to. * Ciphers that are not assigned to any collections are only included for users with admin access. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8032c69ed7c..52c83c5a104 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -691,8 +691,14 @@ export class CipherService implements CipherServiceAbstraction { .sort((a, b) => this.sortCiphersByLastUsedThenName(a, b)); } - async getAllFromApiForOrganization(organizationId: string): Promise { - const response = await this.apiService.getCiphersOrganization(organizationId); + async getAllFromApiForOrganization( + organizationId: string, + includeMemberItems?: boolean, + ): Promise { + const response = await this.apiService.getCiphersOrganization( + organizationId, + includeMemberItems, + ); return await this.decryptOrganizationCiphersResponse(response, organizationId); } From 9b23b2d1b05aade55db6a5986f9194e23a6545c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:57:59 -0400 Subject: [PATCH 26/35] [deps]: Update uuid to v13 (#16636) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../native-messaging-test-runner/package-lock.json | 12 +++++++----- .../native-messaging-test-runner/package.json | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 718bf7efb39..3b976891014 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -15,7 +15,7 @@ "@bitwarden/storage-core": "file:../../../libs/storage-core", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "11.1.0", + "uuid": "13.0.0", "yargs": "18.0.0" }, "devDependencies": { @@ -121,6 +121,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -336,6 +337,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -351,16 +353,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 35a110c3958..0ca9cdc3a17 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -20,7 +20,7 @@ "@bitwarden/logging": "dist/libs/logging/src", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "11.1.0", + "uuid": "13.0.0", "yargs": "18.0.0" }, "devDependencies": { From 2d34a19b23dc1497736ee6f1404b55a5c0844912 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:41:38 -0500 Subject: [PATCH 27/35] [PM-25287] Add AddMasterPasswordUnlockData state migration (#16202) * Add AddMasterPasswordUnlockData state migration --- libs/state/src/state-migrations/migrate.ts | 6 +- ...73-add-master-password-unlock-data.spec.ts | 155 ++++++++++++++++++ .../73-add-master-password-unlock-data.ts | 72 ++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts create mode 100644 libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts diff --git a/libs/state/src/state-migrations/migrate.ts b/libs/state/src/state-migrations/migrate.ts index 620c2d3bb19..bf4cd17adba 100644 --- a/libs/state/src/state-migrations/migrate.ts +++ b/libs/state/src/state-migrations/migrate.ts @@ -69,12 +69,13 @@ import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric- import { RemoveAcBannersDismissed } from "./migrations/70-remove-ac-banner-dismissed"; import { RemoveNewCustomizationOptionsCalloutDismissed } from "./migrations/71-remove-new-customization-options-callout-dismissed"; import { RemoveAccountDeprovisioningBannerDismissed } from "./migrations/72-remove-account-deprovisioning-banner-dismissed"; +import { AddMasterPasswordUnlockData } from "./migrations/73-add-master-password-unlock-data"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 72; +export const CURRENT_VERSION = 73; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -148,7 +149,8 @@ export function createMigrationBuilder() { .with(MigrateIncorrectFolderKey, 68, 69) .with(RemoveAcBannersDismissed, 69, 70) .with(RemoveNewCustomizationOptionsCalloutDismissed, 70, 71) - .with(RemoveAccountDeprovisioningBannerDismissed, 71, CURRENT_VERSION); + .with(RemoveAccountDeprovisioningBannerDismissed, 71, 72) + .with(AddMasterPasswordUnlockData, 72, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts new file mode 100644 index 00000000000..28e65216653 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts @@ -0,0 +1,155 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { AddMasterPasswordUnlockData } from "./73-add-master-password-unlock-data"; + +describe("AddMasterPasswordUnlockData", () => { + const sut = new AddMasterPasswordUnlockData(72, 73); + + describe("migrate", () => { + it("updates users that don't have master password unlock data", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + user2: { + email: "user2@email.com", + name: "User 2", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user2_masterPassword_masterKeyEncryptedUserKey: "user2MasterKeyEncryptedUser", + user_user2_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600001 }, + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + user2: { + email: "user2@email.com", + name: "User 2", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user1_masterPasswordUnlock_masterPasswordUnlockKey: { + salt: "user1@email.com", + kdf: { kdfType: 0, iterations: 600000 }, + masterKeyWrappedUserKey: "user1MasterKeyEncryptedUser", + }, + user_user2_masterPassword_masterKeyEncryptedUserKey: "user2MasterKeyEncryptedUser", + user_user2_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600001 }, + user_user2_masterPasswordUnlock_masterPasswordUnlockKey: { + salt: "user2@email.com", + kdf: { kdfType: 0, iterations: 600001 }, + masterKeyWrappedUserKey: "user2MasterKeyEncryptedUser", + }, + }); + }); + + it("does not update users that already have master password unlock data", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user1_masterPasswordUnlock_masterPasswordUnlockKey: { someData: "data" }, + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user1_masterPasswordUnlock_masterPasswordUnlockKey: { someData: "data" }, + }); + }); + + it("does not update users that have missing data required to construct master password unlock data", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + name: "User 1", + }, + }, + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + name: "User 1", + }, + }, + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + }); + }); + }); + + describe("rollback", () => { + it("rolls back data", async () => { + const output = await runMigrator( + sut, + { + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + user2: { + email: "user2@email.com", + name: "User 2", + }, + user3: { + email: "user3@email.com", + name: "User 3", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user2_masterPassword_masterKeyEncryptedUserKey: "user2MasterKeyEncryptedUser", + user_user2_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600001 }, + user_user1_masterPasswordUnlock_masterPasswordUnlockKey: "fakeData", + user_user2_masterPasswordUnlock_masterPasswordUnlockKey: "fakeData", + user_user3_masterPasswordUnlock_masterPasswordUnlockKey: null, + }, + "rollback", + ); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.Com", + name: "User 1", + }, + user2: { + email: "user2@email.com", + name: "User 2", + }, + user3: { + email: "user3@email.com", + name: "User 3", + }, + }, + user_user1_masterPassword_masterKeyEncryptedUserKey: "user1MasterKeyEncryptedUser", + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + user_user2_masterPassword_masterKeyEncryptedUserKey: "user2MasterKeyEncryptedUser", + user_user2_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600001 }, + user_user3_masterPasswordUnlock_masterPasswordUnlockKey: null, + }); + }); + }); +}); diff --git a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts new file mode 100644 index 00000000000..b9833f439a6 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts @@ -0,0 +1,72 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = { + stateDefinition: { + name: "account", + }, + key: "accounts", +}; + +export const MASTER_PASSWORD_UNLOCK_KEY: KeyDefinitionLike = { + key: "masterPasswordUnlockKey", + stateDefinition: { name: "masterPasswordUnlock" }, +}; + +export const MASTER_KEY_ENCRYPTED_USER_KEY: KeyDefinitionLike = { + key: "masterKeyEncryptedUserKey", + stateDefinition: { name: "masterPassword" }, +}; + +export const KDF_CONFIG_DISK: KeyDefinitionLike = { + key: "kdfConfig", + stateDefinition: { name: "kdfConfig" }, +}; + +type AccountsMap = Record; +type Account = { + email: string; + name: string; +}; + +export class AddMasterPasswordUnlockData extends Migrator<72, 73> { + async migrate(helper: MigrationHelper): Promise { + async function migrateAccount(userId: string, account: Account) { + const email = account.email; + const kdfConfig = await helper.getFromUser(userId, KDF_CONFIG_DISK); + const masterKeyEncryptedUserKey = await helper.getFromUser( + userId, + MASTER_KEY_ENCRYPTED_USER_KEY, + ); + if ( + (await helper.getFromUser(userId, MASTER_PASSWORD_UNLOCK_KEY)) == null && + email != null && + kdfConfig != null && + masterKeyEncryptedUserKey != null + ) { + await helper.setToUser(userId, MASTER_PASSWORD_UNLOCK_KEY, { + salt: email.trim().toLowerCase(), + kdf: kdfConfig, + masterKeyWrappedUserKey: masterKeyEncryptedUserKey, + }); + } + } + + const accountDictionary = await helper.getFromGlobal(ACCOUNT_ACCOUNTS); + const accounts = await helper.getAccounts(); + await Promise.all( + accounts.map(({ userId }) => migrateAccount(userId, accountDictionary[userId])), + ); + } + + async rollback(helper: MigrationHelper): Promise { + async function rollbackAccount(userId: string) { + if ((await helper.getFromUser(userId, MASTER_PASSWORD_UNLOCK_KEY)) != null) { + await helper.removeFromUser(userId, MASTER_PASSWORD_UNLOCK_KEY); + } + } + + const accounts = await helper.getAccounts(); + await Promise.all(accounts.map(({ userId }) => rollbackAccount(userId))); + } +} From d6785037ba902c7a748f81a8b550279ffad89fad Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:53:39 -0400 Subject: [PATCH 28/35] PM-27254 Fix password change progress card reactivity (#16984) --- .../password-change-metric.component.ts | 97 +++++++++++-------- 1 file changed, 56 insertions(+), 41 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/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 index 910b326c662..941d693940b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/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,7 +1,15 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + OnInit, + inject, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs"; +import { switchMap, of, BehaviorSubject, combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -26,6 +34,8 @@ import { AccessIntelligenceSecurityTasksService } from "../../shared/security-ta providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class PasswordChangeMetricComponent implements OnInit { + private destroyRef = inject(DestroyRef); + protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); private completedTasks: number = 0; private totalTasks: number = 0; @@ -34,14 +44,22 @@ export class PasswordChangeMetricComponent implements OnInit { atRiskAppsCount: number = 0; atRiskPasswordsCount: number = 0; private organizationId!: OrganizationId; - private destroyRef = new Subject(); renderMode: RenderMode = "noCriticalApps"; + // Computed properties (formerly getters) - updated when data changes + protected completedPercent = 0; + protected completedTasksCount = 0; + protected totalTasksCount = 0; + protected canAssignTasks = false; + protected hasExistingTasks = false; + protected newAtRiskPasswordsCount = 0; + constructor( private activatedRoute: ActivatedRoute, private securityTasksApiService: SecurityTasksApiService, private allActivitiesService: AllActivitiesService, protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private cdr: ChangeDetectorRef, ) {} async ngOnInit(): Promise { @@ -55,10 +73,11 @@ export class PasswordChangeMetricComponent implements OnInit { } return of({ totalTasks: 0, completedTasks: 0 }); }), - takeUntil(this.destroyRef), + takeUntilDestroyed(this.destroyRef), ) .subscribe((metrics) => { this.taskMetrics$.next(metrics); + this.cdr.markForCheck(); }); combineLatest([ @@ -67,7 +86,7 @@ export class PasswordChangeMetricComponent implements OnInit { this.allActivitiesService.atRiskPasswordsCount$, this.allActivitiesService.allApplicationsDetails$, ]) - .pipe(takeUntil(this.destroyRef)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(([taskMetrics, summary, atRiskPasswordsCount, allApplicationsDetails]) => { this.atRiskAppsCount = summary.totalCriticalAtRiskApplicationCount; this.atRiskPasswordsCount = atRiskPasswordsCount; @@ -81,6 +100,11 @@ export class PasswordChangeMetricComponent implements OnInit { this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, ); + + // Update all computed properties when data changes + this.updateComputedProperties(); + + this.cdr.markForCheck(); }); } @@ -116,57 +140,48 @@ export class PasswordChangeMetricComponent implements OnInit { return RenderMode.noCriticalApps; } - get completedPercent(): number { - if (this.totalTasks === 0) { - return 0; - } - return Math.round((this.completedTasks / this.totalTasks) * 100); - } + /** + * Updates all computed properties based on current state. + * Called whenever data changes to avoid recalculation on every change detection cycle. + */ + private updateComputedProperties(): void { + // Calculate completion percentage + this.completedPercent = + this.totalTasks === 0 ? 0 : Math.round((this.completedTasks / this.totalTasks) * 100); - get completedTasksCount(): number { + // Calculate completed tasks count based on render mode switch (this.renderMode) { case RenderMode.noCriticalApps: case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - return 0; - + this.completedTasksCount = 0; + break; case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - return this.completedTasks; - + this.completedTasksCount = this.completedTasks; + break; default: - return 0; + this.completedTasksCount = 0; } - } - get totalTasksCount(): number { + // Calculate total tasks count based on render mode switch (this.renderMode) { case RenderMode.noCriticalApps: - return 0; - + this.totalTasksCount = 0; + break; case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - return this.atRiskAppsCount; - + this.totalTasksCount = this.atRiskAppsCount; + break; case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - return this.totalTasks; - + this.totalTasksCount = this.totalTasks; + break; default: - return 0; + this.totalTasksCount = 0; } - } - get canAssignTasks(): boolean { - return this.atRiskPasswordsCount > this.totalTasks; - } - - get hasExistingTasks(): boolean { - return this.totalTasks > 0; - } - - get newAtRiskPasswordsCount(): number { - // Calculate new at-risk passwords as the difference between current count and tasks created - if (this.atRiskPasswordsCount > this.totalTasks) { - return this.atRiskPasswordsCount - this.totalTasks; - } - return 0; + // Calculate flags and counts + this.canAssignTasks = this.atRiskPasswordsCount > this.totalTasks; + this.hasExistingTasks = this.totalTasks > 0; + this.newAtRiskPasswordsCount = + this.atRiskPasswordsCount > this.totalTasks ? this.atRiskPasswordsCount - this.totalTasks : 0; } get renderModes() { From c80e8d1d8bf6a89dfdd015c281b54fbaac6a3210 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 23 Oct 2025 15:05:50 -0400 Subject: [PATCH 29/35] [PM-27123] Account Credit not Showing for Premium Upgrade Payment (#16967) * fix(billing): Add NonTokenizedPaymentMethod type * fix(billing): Add NonTokenizedPayment type as parameter option * fix(billing): Update service for account credit payment and add tests * fix(billing): Add logic to accept account credit and callouts for credit * fix(billing): Add account credit back to premium component * fix(billing): update non-tokenizable payment method and payment service * refactor(billing): update payment component * fix(billing): update premium subscription request * fix(billing): update billing html component account credit logic --- .../billing/clients/account-billing.client.ts | 16 +- .../services/upgrade-payment.service.spec.ts | 151 +++++++++++++++-- .../services/upgrade-payment.service.ts | 42 ++++- .../upgrade-payment.component.html | 2 + .../upgrade-payment.component.ts | 155 ++++++++++++------ .../payment/types/tokenized-payment-method.ts | 6 + 6 files changed, 298 insertions(+), 74 deletions(-) diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index e5b97126fb3..256a06b3ead 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -2,7 +2,11 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { BillingAddress, TokenizedPaymentMethod } from "../payment/types"; +import { + BillingAddress, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../payment/types"; @Injectable() export class AccountBillingClient { @@ -14,11 +18,17 @@ export class AccountBillingClient { } purchasePremiumSubscription = async ( - paymentMethod: TokenizedPaymentMethod, + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise => { const path = `${this.endpoint}/subscription`; - const request = { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + + // Determine the request payload based on the payment method type + const isTokenizedPayment = "token" in paymentMethod; + + const request = isTokenizedPayment + ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } + : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; await this.apiService.send("POST", path, request, true, true); }; } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 653a77dccdc..614fc862577 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -15,8 +15,18 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; -import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients"; -import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types"; +import { + AccountBillingClient, + SubscriberBillingClient, + TaxAmounts, + TaxClient, +} from "../../../../clients"; +import { + BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../../../../payment/types"; import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier"; import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; @@ -30,6 +40,7 @@ describe("UpgradePaymentService", () => { const mockSyncService = mock(); const mockOrganizationService = mock(); const mockAccountService = mock(); + const mockSubscriberBillingClient = mock(); mockApiService.refreshIdentityToken.mockResolvedValue({}); mockSyncService.fullSync.mockResolvedValue(true); @@ -104,6 +115,7 @@ describe("UpgradePaymentService", () => { mockReset(mockLogService); mockReset(mockOrganizationService); mockReset(mockAccountService); + mockReset(mockSubscriberBillingClient); mockAccountService.activeAccount$ = of(null); mockOrganizationService.organizations$.mockReturnValue(of([])); @@ -111,7 +123,10 @@ describe("UpgradePaymentService", () => { TestBed.configureTestingModule({ providers: [ UpgradePaymentService, - + { + provide: SubscriberBillingClient, + useValue: mockSubscriberBillingClient, + }, { provide: OrganizationBillingServiceAbstraction, useValue: mockOrganizationBillingService, @@ -172,6 +187,7 @@ describe("UpgradePaymentService", () => { mockSyncService, mockOrganizationService, mockAccountService, + mockSubscriberBillingClient, ); // Act & Assert @@ -223,6 +239,7 @@ describe("UpgradePaymentService", () => { mockSyncService, mockOrganizationService, mockAccountService, + mockSubscriberBillingClient, ); // Act & Assert @@ -256,6 +273,7 @@ describe("UpgradePaymentService", () => { mockSyncService, mockOrganizationService, mockAccountService, + mockSubscriberBillingClient, ); // Act & Assert @@ -266,6 +284,68 @@ describe("UpgradePaymentService", () => { }); }); + describe("accountCredit$", () => { + it("should correctly fetch account credit for subscriber", (done) => { + // Arrange + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + const expectedCredit = 25.5; + + mockAccountService.activeAccount$ = of(mockAccount); + mockSubscriberBillingClient.getCredit.mockResolvedValue(expectedCredit); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + ); + + // Act & Assert + service.accountCredit$.subscribe((credit) => { + expect(credit).toBe(expectedCredit); + expect(mockSubscriberBillingClient.getCredit).toHaveBeenCalledWith({ + data: mockAccount, + type: "account", + }); + done(); + }); + }); + + it("should handle empty account", (done) => { + // Arrange + mockAccountService.activeAccount$ = of(null); + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + ); + // Act & Assert + service?.accountCredit$.subscribe({ + error: () => { + expect(mockSubscriberBillingClient.getCredit).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); + describe("adminConsoleRouteForOwnedOrganization$", () => { it("should return the admin console route for the first free organization the user owns", (done) => { // Arrange @@ -309,6 +389,7 @@ describe("UpgradePaymentService", () => { mockSyncService, mockOrganizationService, mockAccountService, + mockSubscriberBillingClient, ); // Act & Assert @@ -405,24 +486,58 @@ describe("UpgradePaymentService", () => { expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); - it("should throw error if payment method is incomplete", async () => { + it("should handle upgrade with account credit payment method and refresh data", async () => { // Arrange - const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod; + const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { + type: NonTokenizablePaymentMethods.accountCredit, + }; + mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); - // Act & Assert - await expect( - sut.upgradeToPremium(incompletePaymentMethod, mockBillingAddress), - ).rejects.toThrow("Payment method type or token is missing"); + // Act + await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); + + // Assert + expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + accountCreditPaymentMethod, + mockBillingAddress, + ); + expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); - it("should throw error if billing address is incomplete", async () => { + it("should validate payment method type and token", async () => { // Arrange - const incompleteBillingAddress = { country: "US", postalCode: null } as any; + const noTypePaymentMethod = { token: "test-token" } as any; + const noTokenPaymentMethod = { type: "card" } as TokenizedPaymentMethod; + + // Act & Assert + await expect(sut.upgradeToPremium(noTypePaymentMethod, mockBillingAddress)).rejects.toThrow( + "Payment method type is missing", + ); + + await expect(sut.upgradeToPremium(noTokenPaymentMethod, mockBillingAddress)).rejects.toThrow( + "Payment method token is missing", + ); + }); + + it("should validate billing address fields", async () => { + // Arrange + const missingCountry = { postalCode: "12345" } as any; + const missingPostal = { country: "US" } as any; + const nullFields = { country: "US", postalCode: null } as any; // Act & Assert await expect( - sut.upgradeToPremium(mockTokenizedPaymentMethod, incompleteBillingAddress), + sut.upgradeToPremium(mockTokenizedPaymentMethod, missingCountry), ).rejects.toThrow("Billing address information is incomplete"); + + await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, missingPostal)).rejects.toThrow( + "Billing address information is incomplete", + ); + + await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, nullFields)).rejects.toThrow( + "Billing address information is incomplete", + ); }); }); @@ -504,7 +619,7 @@ describe("UpgradePaymentService", () => { expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1); }); - it("should throw error if payment method is incomplete", async () => { + it("should throw error if payment token is missing with card type", async () => { const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod; await expect( @@ -512,7 +627,15 @@ describe("UpgradePaymentService", () => { organizationName: "Test Organization", billingAddress: mockBillingAddress, }), - ).rejects.toThrow("Payment method type or token is missing"); + ).rejects.toThrow("Payment method token is missing"); + }); + it("should throw error if organization name is missing", async () => { + await expect( + sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, mockTokenizedPaymentMethod, { + organizationName: "", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Organization name is required for families upgrade"); }); }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index 11dd10d4bb8..e175363af33 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -11,21 +11,25 @@ import { OrganizationBillingServiceAbstraction, SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; -import { PlanType } from "@bitwarden/common/billing/enums"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { LogService } from "@bitwarden/logging"; import { AccountBillingClient, OrganizationSubscriptionPurchase, + SubscriberBillingClient, TaxAmounts, TaxClient, } from "../../../../clients"; import { BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, tokenizablePaymentMethodToLegacyEnum, TokenizedPaymentMethod, } from "../../../../payment/types"; +import { mapAccountToSubscriber } from "../../../../types"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, @@ -59,6 +63,7 @@ export class UpgradePaymentService { private syncService: SyncService, private organizationService: OrganizationService, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, ) {} userIsOwnerOfFreeOrg$: Observable = this.accountService.activeAccount$.pipe( @@ -79,6 +84,12 @@ export class UpgradePaymentService { map((org) => `/organizations/${org!.id}/billing/subscription`), ); + // Fetch account credit + accountCredit$: Observable = this.accountService.activeAccount$.pipe( + mapAccountToSubscriber, + switchMap((account) => this.subscriberBillingClient.getCredit(account)), + ); + /** * Calculate estimated tax for the selected plan */ @@ -130,7 +141,7 @@ export class UpgradePaymentService { * Process premium upgrade */ async upgradeToPremium( - paymentMethod: TokenizedPaymentMethod, + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); @@ -169,10 +180,7 @@ export class UpgradePaymentService { passwordManagerSeats: passwordManagerSeats, }, payment: { - paymentMethod: [ - paymentMethod.token, - tokenizablePaymentMethodToLegacyEnum(paymentMethod.type), - ], + paymentMethod: [paymentMethod.token, this.getPaymentMethodType(paymentMethod)], billing: { country: billingAddress.country, postalCode: billingAddress.postalCode, @@ -195,11 +203,19 @@ export class UpgradePaymentService { } private validatePaymentAndBillingInfo( - paymentMethod: TokenizedPaymentMethod, + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: { country: string; postalCode: string }, ): void { - if (!paymentMethod?.token || !paymentMethod?.type) { - throw new Error("Payment method type or token is missing"); + if (!paymentMethod?.type) { + throw new Error("Payment method type is missing"); + } + + // Account credit does not require a token + if ( + paymentMethod.type !== NonTokenizablePaymentMethods.accountCredit && + !paymentMethod?.token + ) { + throw new Error("Payment method token is missing"); } if (!billingAddress?.country || !billingAddress?.postalCode) { @@ -211,4 +227,12 @@ export class UpgradePaymentService { await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); } + + private getPaymentMethodType( + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, + ): PaymentMethodType { + return paymentMethod.type === NonTokenizablePaymentMethods.accountCredit + ? PaymentMethodType.Credit + : tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + } } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index fad883f942a..2228a6f6c06 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -34,8 +34,10 @@
{{ "paymentMethod" | i18n }}
{{ "billingAddress" | i18n }}
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 0b785d44e95..740e4eadff9 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -10,7 +10,16 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { catchError, debounceTime, from, Observable, of, switchMap } from "rxjs"; +import { + debounceTime, + Observable, + switchMap, + startWith, + from, + catchError, + of, + combineLatest, +} from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,7 +32,14 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, + getBillingAddressFromForm, } from "../../../payment/components"; +import { + BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../../../payment/types"; import { BillingServicesModule } from "../../../services"; import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; import { BitwardenSubscriber } from "../../../types"; @@ -33,7 +49,11 @@ import { PersonalSubscriptionPricingTierIds, } from "../../../types/subscription-pricing-tier"; -import { PlanDetails, UpgradePaymentService } from "./services/upgrade-payment.service"; +import { + PaymentFormValues, + PlanDetails, + UpgradePaymentService, +} from "./services/upgrade-payment.service"; /** * Status types for upgrade payment dialog @@ -80,6 +100,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected goBack = output(); protected complete = output(); protected selectedPlan: PlanDetails | null = null; + protected hasEnoughAccountCredit$!: Observable; @ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent; @ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent; @@ -155,6 +176,22 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { .subscribe((tax) => { this.estimatedTax = tax; }); + + // Check if user has enough account credit for the purchase + this.hasEnoughAccountCredit$ = combineLatest([ + this.upgradePaymentService.accountCredit$, + this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + ]).pipe( + switchMap(([credit, formValue]) => { + const selectedPaymentType = formValue.paymentForm?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return of(true); // Not using account credit, so this check doesn't apply + } + + return credit ? of(credit >= this.cartSummaryComponent.total()) : of(false); + }), + ); + this.loading.set(false); } @@ -210,76 +247,98 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } private async processUpgrade(): Promise { - // Get common values - const country = this.formGroup.value?.billingAddress?.country; - const postalCode = this.formGroup.value?.billingAddress?.postalCode; - if (!this.selectedPlan) { throw new Error("No plan selected"); } - if (!country || !postalCode) { + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const organizationName = this.formGroup.value?.organizationName; + + if (!billingAddress.country || !billingAddress.postalCode) { throw new Error("Billing address is incomplete"); } - // Validate organization name for Families plan - const organizationName = this.formGroup.value?.organizationName; if (this.isFamiliesPlan && !organizationName) { throw new Error("Organization name is required"); } - // Get payment method - const tokenizedPaymentMethod = await this.paymentComponent?.tokenize(); + const paymentMethod = await this.getPaymentMethod(); - if (!tokenizedPaymentMethod) { + if (!paymentMethod) { throw new Error("Payment method is required"); } - // Process the upgrade based on plan type - if (this.isFamiliesPlan) { - const paymentFormValues = { - organizationName, - billingAddress: { country, postalCode }, - }; + const isTokenizedPayment = "token" in paymentMethod; - const response = await this.upgradePaymentService.upgradeToFamilies( - this.account(), - this.selectedPlan, - tokenizedPaymentMethod, - paymentFormValues, - ); - - return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id }; - } else { - await this.upgradePaymentService.upgradeToPremium(tokenizedPaymentMethod, { - country, - postalCode, - }); - return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null }; + if (!isTokenizedPayment && this.isFamiliesPlan) { + throw new Error("Tokenized payment is required for families plan"); } + + return this.isFamiliesPlan + ? this.processFamiliesUpgrade( + organizationName!, + billingAddress, + paymentMethod as TokenizedPaymentMethod, + ) + : this.processPremiumUpgrade(paymentMethod, billingAddress); + } + + private async processFamiliesUpgrade( + organizationName: string, + billingAddress: BillingAddress, + paymentMethod: TokenizedPaymentMethod, + ): Promise { + const paymentFormValues: PaymentFormValues = { + organizationName, + billingAddress, + }; + + const response = await this.upgradePaymentService.upgradeToFamilies( + this.account(), + this.selectedPlan!, + paymentMethod, + paymentFormValues, + ); + + return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id }; + } + + private async processPremiumUpgrade( + paymentMethod: NonTokenizedPaymentMethod | TokenizedPaymentMethod, + billingAddress: BillingAddress, + ): Promise { + await this.upgradePaymentService.upgradeToPremium(paymentMethod, billingAddress); + return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null }; + } + + /** + * Get payment method based on selected type + * If using account credit, returns a non-tokenized payment method + * Otherwise, tokenizes the payment method from the payment component + */ + private async getPaymentMethod(): Promise< + NonTokenizedPaymentMethod | TokenizedPaymentMethod | null + > { + const isAccountCreditSelected = + this.formGroup.value?.paymentForm?.type === NonTokenizablePaymentMethods.accountCredit; + + if (isAccountCreditSelected) { + return { type: NonTokenizablePaymentMethods.accountCredit }; + } + + return await this.paymentComponent?.tokenize(); } // Create an observable for tax calculation private refreshSalesTax$(): Observable { - const billingAddress = { - country: this.formGroup.value?.billingAddress?.country, - postalCode: this.formGroup.value?.billingAddress?.postalCode, - }; - - if (!this.selectedPlan || !billingAddress.country || !billingAddress.postalCode) { + if (this.formGroup.invalid || !this.selectedPlan) { return of(0); } - // Convert Promise to Observable + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + return from( - this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, { - line1: null, - line2: null, - city: null, - state: null, - country: billingAddress.country, - postalCode: billingAddress.postalCode, - taxId: null, - }), + this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress), ).pipe( catchError((error: unknown) => { this.logService.error("Tax calculation failed:", error); diff --git a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts index 9b867329e66..d2cbfcf5101 100644 --- a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts @@ -17,6 +17,8 @@ export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.acc export type TokenizablePaymentMethod = (typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; +export type NonTokenizablePaymentMethod = + (typeof NonTokenizablePaymentMethods)[keyof typeof NonTokenizablePaymentMethods]; export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => { const valid = Object.values(TokenizablePaymentMethods) as readonly string[]; @@ -40,3 +42,7 @@ export type TokenizedPaymentMethod = { type: TokenizablePaymentMethod; token: string; }; + +export type NonTokenizedPaymentMethod = { + type: NonTokenizablePaymentMethod; +}; From a592f2b866f8f7fc3a905a27d9b1f2cd8225db97 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:18:43 -0400 Subject: [PATCH 30/35] [deps]: Update actions/checkout action to v5 (#16424) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../workflows/alert-ddg-files-modified.yml | 2 +- .github/workflows/auto-branch-updater.yml | 2 +- .github/workflows/build-browser.yml | 12 +++++------ .github/workflows/build-cli.yml | 8 ++++---- .github/workflows/build-desktop.yml | 20 +++++++++---------- .github/workflows/build-web.yml | 8 ++++---- .github/workflows/chromatic.yml | 2 +- .github/workflows/crowdin-pull.yml | 2 +- .github/workflows/lint-crowdin-config.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/locales-lint.yml | 4 ++-- .github/workflows/nx.yml | 2 +- .github/workflows/publish-cli.yml | 6 +++--- .github/workflows/publish-desktop.yml | 6 +++--- .github/workflows/publish-web.yml | 4 ++-- .github/workflows/release-browser.yml | 4 ++-- .github/workflows/release-cli.yml | 2 +- .github/workflows/release-desktop.yml | 2 +- .github/workflows/release-web.yml | 2 +- .github/workflows/repository-management.yml | 4 ++-- .../workflows/test-browser-interactions.yml | 2 +- .github/workflows/test.yml | 8 ++++---- .github/workflows/version-auto-bump.yml | 2 +- 23 files changed, 55 insertions(+), 55 deletions(-) diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml index 84cd67ecd5b..4acab6b1c62 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml index ceebfb7e466..dcd031af0de 100644 --- a/.github/workflows/auto-branch-updater.yml +++ b/.github/workflows/auto-branch-updater.yml @@ -30,7 +30,7 @@ jobs: run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: 'eu-web-${{ steps.setup.outputs.branch }}' fetch-depth: 0 diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index e3a49e414f9..5980ef507cc 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -55,7 +55,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -94,7 +94,7 @@ jobs: working-directory: apps/browser steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -146,7 +146,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -248,7 +248,7 @@ jobs: artifact_name: "dist-opera-MV3" steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -360,7 +360,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -511,7 +511,7 @@ jobs: - build-safari steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 839181c6107..1f7b35f3307 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -59,7 +59,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -114,7 +114,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -306,7 +306,7 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -510,7 +510,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 51a0938552c..39549c4580c 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -88,7 +88,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true @@ -173,7 +173,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -323,7 +323,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -430,7 +430,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -689,7 +689,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} @@ -923,7 +923,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1150,7 +1150,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1411,7 +1411,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1737,7 +1737,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 6733eeca1b4..ee7444f13a9 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -64,7 +64,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -135,7 +135,7 @@ jobs: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -165,7 +165,7 @@ jobs: echo "server_ref=$SERVER_REF" >> "$GITHUB_OUTPUT" - name: Check out Server repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: path: server repository: bitwarden/server @@ -357,7 +357,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 133f5b730b8..ccac9cb32bb 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 3be294145ec..f195afa86da 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -56,7 +56,7 @@ jobs: private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: token: ${{ steps.app-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index 40f73f7fc5a..ee22a03963c 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -22,7 +22,7 @@ jobs: ] steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 1 persist-credentials: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0136bd2f70f..ae4f4f95aa6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -91,7 +91,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/locales-lint.yml b/.github/workflows/locales-lint.yml index 26c910f955e..da79f9aa21f 100644 --- a/.github/workflows/locales-lint.yml +++ b/.github/workflows/locales-lint.yml @@ -17,11 +17,11 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Checkout base branch repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.base.sha }} path: base diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3e14169a065..43361bc983d 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 9bbd982d32f..bcae79d077e 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -101,7 +101,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -149,7 +149,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -201,7 +201,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index a747012467e..2e9ba635e7a 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -221,7 +221,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -275,7 +275,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -332,7 +332,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 9f9cbd5c58e..6bf2b282b38 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -28,7 +28,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -74,7 +74,7 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index a2fda230491..39f54a6e2db 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -28,7 +28,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -61,7 +61,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 918f81e2723..d5013770476 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -29,7 +29,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index a97d72a32b0..9239914aeff 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -31,7 +31,7 @@ jobs: release_channel: ${{ steps.release_channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index d616d7adb3f..8c8f8ed86af 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -25,7 +25,7 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index acfda4cdb11..ce9b70118b2 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -104,7 +104,7 @@ jobs: private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out branch - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: main token: ${{ steps.app-token.outputs.token }} @@ -469,7 +469,7 @@ jobs: private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index a05f506d63f..a5b92563f5a 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -18,7 +18,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf62df3180f..d468ca74ed6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -103,7 +103,7 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -137,7 +137,7 @@ jobs: runs-on: macos-14 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false @@ -173,7 +173,7 @@ jobs: - rust-coverage steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 0f7f2c9f46d..fee34d14e83 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -38,7 +38,7 @@ jobs: private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: main token: ${{ steps.app-token.outputs.token }} From e3f943364f519e4d3d5465b783100d35d3beeab7 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 23 Oct 2025 22:02:01 +0200 Subject: [PATCH 31/35] Billing - Prefer signal & change detection (#16944) --- .../popup/settings/premium-v2.component.ts | 2 ++ .../billing/app/accounts/premium.component.ts | 2 ++ .../billing-history-view.component.ts | 2 ++ .../account-payment-details.component.ts | 2 ++ .../premium/premium-vnext.component.ts | 2 ++ .../individual/premium/premium.component.ts | 4 +++ .../individual/subscription.component.ts | 2 ++ .../unified-upgrade-dialog.component.spec.ts | 4 +++ .../unified-upgrade-dialog.component.ts | 2 ++ .../upgrade-account.component.ts | 2 ++ .../upgrade-nav-button.component.ts | 2 ++ .../upgrade-payment.component.ts | 6 ++++ .../individual/user-subscription.component.ts | 2 ++ .../add-sponsorship-dialog.component.ts | 2 ++ .../free-bitwarden-families.component.ts | 4 ++- .../adjust-subscription.component.ts | 14 ++++++++ .../billing-sync-api-key.component.ts | 2 ++ .../billing-sync-key.component.ts | 2 ++ .../change-plan-dialog.component.ts | 24 ++++++++++++++ .../organizations/change-plan.component.ts | 12 +++++++ .../download-license.component.ts | 2 ++ ...nization-billing-history-view.component.ts | 2 ++ .../organization-plans.component.ts | 32 +++++++++++++++++++ ...ganization-subscription-cloud.component.ts | 2 ++ ...ization-subscription-selfhost.component.ts | 2 ++ .../organization-payment-details.component.ts | 2 ++ .../sm-adjust-subscription.component.ts | 8 +++++ .../sm-subscribe-standalone.component.ts | 10 ++++++ .../subscription-hidden.component.ts | 4 +++ .../subscription-status.component.ts | 6 ++++ ...ganization-free-trial-warning.component.ts | 8 +++++ ...tion-reseller-renewal-warning.component.ts | 4 +++ .../add-account-credit-dialog.component.ts | 4 +++ .../change-payment-method-dialog.component.ts | 2 ++ .../display-account-credit.component.ts | 6 ++++ .../display-billing-address.component.ts | 10 ++++++ .../display-payment-method.component.ts | 8 +++++ .../edit-billing-address-dialog.component.ts | 2 ++ .../enter-billing-address.component.ts | 6 ++++ .../enter-payment-method.component.ts | 14 ++++++++ .../components/payment-label.component.ts | 6 ++++ ...require-payment-method-dialog.component.ts | 2 ++ .../submit-payment-method-dialog.component.ts | 4 +++ .../verify-bank-account.component.ts | 6 ++++ .../settings/sponsored-families.component.ts | 2 ++ .../settings/sponsoring-org-row.component.ts | 8 +++++ .../adjust-storage-dialog.component.ts | 2 ++ ...illing-free-families-nav-item.component.ts | 2 ++ .../shared/billing-history.component.ts | 8 +++++ .../shared/offboarding-survey.component.ts | 2 ++ .../shared/plan-card/plan-card.component.ts | 4 ++- .../pricing-summary.component.ts | 4 +++ ...self-hosting-license-uploader.component.ts | 4 +++ ...self-hosting-license-uploader.component.ts | 4 +++ .../billing/shared/sm-subscribe.component.ts | 12 +++++++ .../trial-payment-dialog.component.ts | 8 ++++- .../shared/update-license-dialog.component.ts | 2 ++ .../shared/update-license.component.ts | 12 +++++++ .../complete-trial-initiation.component.ts | 4 +++ .../confirmation-details.component.ts | 10 ++++++ .../trial-billing-step.component.ts | 6 ++++ .../vertical-step-content.component.ts | 12 +++++++ .../vertical-step.component.ts | 8 +++++ .../vertical-stepper.component.ts | 4 +++ .../components/tax-id-warning.component.ts | 8 +++++ ...-existing-organization-dialog.component.ts | 2 ++ .../clients/create-client-dialog.component.ts | 2 ++ .../manage-client-name-dialog.component.ts | 2 ++ ...ge-client-subscription-dialog.component.ts | 2 ++ .../clients/manage-clients.component.ts | 2 ++ .../providers/clients/no-clients.component.ts | 8 +++++ .../free-families-sponsorship.component.ts | 2 ++ .../billing-history/invoices.component.ts | 10 ++++++ .../billing-history/no-invoices.component.ts | 2 ++ .../provider-billing-history.component.ts | 2 ++ .../provider-payment-details.component.ts | 2 ++ .../setup/setup-business-unit.component.ts | 2 ++ .../provider-subscription-status.component.ts | 4 +++ .../provider-subscription.component.ts | 2 ++ .../premium-badge/premium-badge.component.ts | 4 ++- .../cart-summary/cart-summary.component.ts | 12 ++++--- .../pricing-card.component.spec.ts | 2 ++ .../pricing-card/pricing-card.component.ts | 4 +++ 83 files changed, 429 insertions(+), 9 deletions(-) diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.ts b/apps/browser/src/billing/popup/settings/premium-v2.component.ts index fde44688349..b858b74242d 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.ts +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.ts @@ -26,6 +26,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({ selector: "app-premium", templateUrl: "premium-v2.component.html", diff --git a/apps/desktop/src/billing/app/accounts/premium.component.ts b/apps/desktop/src/billing/app/accounts/premium.component.ts index 5d0fa7a5dde..637969c1a21 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.ts +++ b/apps/desktop/src/billing/app/accounts/premium.component.ts @@ -10,6 +10,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +// 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-premium", templateUrl: "premium.component.html", diff --git a/apps/web/src/app/billing/individual/billing-history-view.component.ts b/apps/web/src/app/billing/individual/billing-history-view.component.ts index d615e01d0db..607a35baa94 100644 --- a/apps/web/src/app/billing/individual/billing-history-view.component.ts +++ b/apps/web/src/app/billing/individual/billing-history-view.component.ts @@ -10,6 +10,8 @@ import { } from "@bitwarden/common/billing/models/response/billing.response"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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({ templateUrl: "billing-history-view.component.html", standalone: false, diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index ca7902542de..8c061894fac 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -19,6 +19,8 @@ type View = { credit: number | null; }; +// 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: "./account-payment-details.component.html", standalone: true, diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index 61994fdb61d..32c8061b10b 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -42,6 +42,8 @@ import { UnifiedUpgradeDialogStep, } from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.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: "./premium-vnext.component.html", standalone: true, 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 526b020a9e3..6754f4c9f50 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -42,12 +42,16 @@ import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/ser import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; +// 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: "./premium.component.html", standalone: false, providers: [SubscriberBillingClient, TaxClient], }) export class PremiumComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index 2a08ec85127..37fb2baf3a6 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -7,6 +7,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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({ templateUrl: "subscription.component.html", standalone: false, diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 1d707cec75f..d0960251724 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -26,6 +26,8 @@ import { UnifiedUpgradeDialogStep, } from "./unified-upgrade-dialog.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({ selector: "app-upgrade-account", template: "", @@ -38,6 +40,8 @@ class MockUpgradeAccountComponent { closeClicked = output(); } +// 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-upgrade-payment", template: "", diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 0d9c8902d6c..077490cef43 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -62,6 +62,8 @@ export type UnifiedUpgradeDialogParams = { redirectOnCompletion?: 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-unified-upgrade-dialog", imports: [ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index c9b8f22d046..be09505d190 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -39,6 +39,8 @@ type CardDetails = { features: string[]; }; +// 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-upgrade-account", imports: [ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts index 3d6f5b985ec..57d3b996e90 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -14,6 +14,8 @@ import { UnifiedUpgradeDialogStatus, } from "../../unified-upgrade-dialog/unified-upgrade-dialog.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({ selector: "app-upgrade-nav-button", imports: [I18nPipe], diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 740e4eadff9..5ad465455f2 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -80,6 +80,8 @@ export type UpgradePaymentParams = { subscriber: BitwardenSubscriber; }; +// 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-upgrade-payment", imports: [ @@ -102,7 +104,11 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected selectedPlan: PlanDetails | null = null; protected hasEnoughAccountCredit$!: Observable; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent; protected formGroup = new FormGroup({ diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 4d1fa97785b..19db9ec8e61 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -26,6 +26,8 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// 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: "user-subscription.component.html", standalone: false, diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts index 38ae39cabfe..971cfb5704b 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts @@ -38,6 +38,8 @@ interface AddSponsorshipDialogParams { organizationKey: OrgKey; } +// 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: "add-sponsorship-dialog.component.html", imports: [ diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index dc4a2f6df9b..474e513da6b 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -20,13 +20,15 @@ import { KeyService } from "@bitwarden/key-management"; import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.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({ selector: "app-free-bitwarden-families", templateUrl: "free-bitwarden-families.component.html", standalone: false, }) export class FreeBitwardenFamiliesComponent implements OnInit { - loading = signal(true); + readonly loading = signal(true); tabIndex = 0; sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = []; diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts index d1086a6646b..7ee5891e8a9 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts @@ -16,17 +16,31 @@ import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +// 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-adjust-subscription", templateUrl: "adjust-subscription.component.html", standalone: false, }) export class AdjustSubscription implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() maxAutoscaleSeats: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentSeatCount: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() seatPrice = 0; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() interval = "year"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAdjusted = new EventEmitter(); private destroy$ = new Subject(); diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts index 55687f00052..52a7fab60f5 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts @@ -20,6 +20,8 @@ export interface BillingSyncApiModalData { hasBillingToken: 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({ templateUrl: "billing-sync-api-key.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/billing-sync-key.component.ts b/apps/web/src/app/billing/organizations/billing-sync-key.component.ts index 37ebefc803a..c6c2bf379eb 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-key.component.ts +++ b/apps/web/src/app/billing/organizations/billing-sync-key.component.ts @@ -19,6 +19,8 @@ export interface BillingSyncKeyModalData { setParentConnection: (connection: OrganizationConnectionResponse) => void; } +// 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: "billing-sync-key.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index c2c819ddf4d..ac415ac4be2 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -105,6 +105,8 @@ interface OnSuccessArgs { organizationId: string; } +// 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: "./change-plan-dialog.component.html", imports: [ @@ -116,13 +118,25 @@ interface OnSuccessArgs { providers: [SubscriberBillingClient, TaxClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showFree = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showCancel = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get productTier(): ProductTierType { return this._productTier; @@ -136,6 +150,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { protected estimatedTax: number = 0; private _productTier = ProductTierType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get plan(): PlanType { return this._plan; @@ -147,9 +163,17 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } private _plan = PlanType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccess = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onTrialBillingSuccess = new EventEmitter(); protected discountPercentageFromSub: number; diff --git a/apps/web/src/app/billing/organizations/change-plan.component.ts b/apps/web/src/app/billing/organizations/change-plan.component.ts index 31cbf4e94bf..a3f14f5ce29 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan.component.ts @@ -6,16 +6,28 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { LogService } from "@bitwarden/common/platform/abstractions/log.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-change-plan", templateUrl: "change-plan.component.html", standalone: false, }) export class ChangePlanComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentPlan: PlanResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() preSelectedProductTier: ProductTierType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChanged = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); formPromise: Promise; diff --git a/apps/web/src/app/billing/organizations/download-license.component.ts b/apps/web/src/app/billing/organizations/download-license.component.ts index 8ada57e8377..e93ae5028dc 100644 --- a/apps/web/src/app/billing/organizations/download-license.component.ts +++ b/apps/web/src/app/billing/organizations/download-license.component.ts @@ -18,6 +18,8 @@ type DownloadLicenseDialogData = { organizationId: string; }; +// 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: "download-license.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts b/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts index ce4678ad8ef..a654ac272fe 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts @@ -10,6 +10,8 @@ import { BillingTransactionResponse, } from "@bitwarden/common/billing/models/response/billing.response"; +// 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: "organization-billing-history-view.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index cbeedc454dc..a4ebba7a760 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -72,6 +72,8 @@ const Allowed2020PlansForLegacyProviders = [ PlanType.EnterpriseMonthly2020, ]; +// 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-organization-plans", templateUrl: "organization-plans.component.html", @@ -84,17 +86,33 @@ const Allowed2020PlansForLegacyProviders = [ providers: [SubscriberBillingClient, TaxClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showFree = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showCancel = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() planSponsorshipType?: PlanSponsorshipType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentPlan: PlanResponse; selectedFile: File; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get productTier(): ProductTierType { return this._productTier; @@ -107,6 +125,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private _productTier = ProductTierType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get plan(): PlanType { return this._plan; @@ -116,13 +136,25 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this._plan = plan; this.formGroup?.controls?.plan?.setValue(plan); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enableSecretsManagerByDefault: boolean; private _plan = PlanType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() preSelectedProductTier?: ProductTierType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccess = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onTrialBillingSuccess = new EventEmitter(); loading = true; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 79d4057fdd7..fc9f8b1d986 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -42,6 +42,8 @@ import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan- import { DownloadLicenceDialogComponent } from "./download-license.component"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.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: "organization-subscription-cloud.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts index fa4b633cb7a..905e682ceca 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts @@ -34,6 +34,8 @@ enum LicenseOptions { UPLOAD = 1, } +// 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: "organization-subscription-selfhost.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index b2bf27e726a..9609160089b 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -60,6 +60,8 @@ const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{ organizationId: st "organizationBankAccountVerified", ); +// 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: "./organization-payment-details.component.html", standalone: true, diff --git a/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts index 33413832865..5fa6971bac6 100644 --- a/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts @@ -56,14 +56,22 @@ export interface SecretsManagerSubscriptionOptions { additionalServiceAccountPrice: number; } +// 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-sm-adjust-subscription", templateUrl: "sm-adjust-subscription.component.html", standalone: false, }) export class SecretsManagerAdjustSubscriptionComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() options: SecretsManagerSubscriptionOptions; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAdjusted = new EventEmitter(); private destroy$ = new Subject(); diff --git a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts index 6f9525e4fce..1ef705fd4bd 100644 --- a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts +++ b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts @@ -20,15 +20,25 @@ import { ToastService } from "@bitwarden/components"; import { secretsManagerSubscribeFormFactory } from "../shared"; +// 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: "sm-subscribe-standalone", templateUrl: "sm-subscribe-standalone.component.html", standalone: false, }) export class SecretsManagerSubscribeStandaloneComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() plan: PlanResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organization: Organization; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() customerDiscount: BillingCustomerDiscount; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSubscribe = new EventEmitter(); formGroup = secretsManagerSubscribeFormFactory(this.formBuilder); diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index cca12e938d2..d56167d6d70 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -4,6 +4,8 @@ import { Component, Input } from "@angular/core"; import { GearIcon } from "@bitwarden/assets/svg"; +// 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-org-subscription-hidden", template: `
@@ -16,6 +18,8 @@ import { GearIcon } from "@bitwarden/assets/svg"; standalone: false, }) export class SubscriptionHiddenComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerName: string; gearIcon = GearIcon; } diff --git a/apps/web/src/app/billing/organizations/subscription-status.component.ts b/apps/web/src/app/billing/organizations/subscription-status.component.ts index 0b59df3f707..54a309a441b 100644 --- a/apps/web/src/app/billing/organizations/subscription-status.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-status.component.ts @@ -23,13 +23,19 @@ type ComponentData = { }; }; +// 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-subscription-status", templateUrl: "subscription-status.component.html", standalone: false, }) export class SubscriptionStatusComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organizationSubscriptionResponse: OrganizationSubscriptionResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() reinstatementRequested = new EventEmitter(); constructor( diff --git a/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index 8390e432236..debac3cb2f7 100644 --- a/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -8,6 +8,8 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; +// 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-organization-free-trial-warning", template: ` @@ -36,8 +38,14 @@ import { OrganizationFreeTrialWarning } from "../types"; imports: [BannerModule, SharedModule], }) export class OrganizationFreeTrialWarningComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organization!: Organization; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() includeOrganizationNameInMessaging = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() clicked = new EventEmitter(); warning$!: Observable; diff --git a/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index c49f59f6b05..e9850b55c9e 100644 --- a/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -8,6 +8,8 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; +// 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-organization-reseller-renewal-warning", template: ` @@ -27,6 +29,8 @@ import { OrganizationResellerRenewalWarning } from "../types"; imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organization!: Organization; warning$!: Observable; diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index a83a00e8158..1bc08159cdf 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -52,6 +52,8 @@ const positiveNumberValidator = return null; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: `
@@ -128,6 +130,8 @@ const positiveNumberValidator = providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; protected payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index 4d2fadaa894..71d156ecb26 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -18,6 +18,8 @@ type DialogParams = { subscriber: BitwardenSubscriber; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index f6aa0ef58bb..b4684f0d739 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -10,6 +10,8 @@ import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.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({ selector: "app-display-account-credit", template: ` @@ -26,7 +28,11 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) credit!: number | null; constructor( diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index 03d21a79003..2c5b7986c7b 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -12,6 +12,8 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +// 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-display-billing-address", template: ` @@ -48,9 +50,17 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; imports: [AddressPipe, SharedModule], }) export class DisplayBillingAddressComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) billingAddress!: BillingAddress | null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() taxIdWarning?: TaxIdWarningType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index 5f5e3442935..c5ffa4268ed 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -9,6 +9,8 @@ import { getCardBrandIcon, MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.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({ selector: "app-display-payment-method", template: ` @@ -70,8 +72,14 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial imports: [SharedModule], }) export class DisplayPaymentMethodComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index 6e356097d32..aa9d2830527 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -35,6 +35,8 @@ type DialogResult = | { type: "error" } | { type: "success"; billingAddress: BillingAddress }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index 3f68c12c897..40785e9b7ea 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -47,6 +47,8 @@ type Scenario = taxIdWarning?: TaxIdWarningType; }; +// 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-enter-billing-address", template: ` @@ -159,7 +161,11 @@ type Scenario = imports: [SharedModule], }) export class EnterBillingAddressComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) scenario!: Scenario; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) group!: BillingAddressFormGroup; protected selectableCountries = selectableCountries; diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index c0a9027388d..b75a4acb602 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -34,6 +34,8 @@ type PaymentMethodFormGroup = FormGroup<{ }>; }>; +// 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-enter-payment-method", template: ` @@ -232,12 +234,24 @@ type PaymentMethodFormGroup = FormGroup<{ imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule], }) export class EnterPaymentMethodComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) group!: PaymentMethodFormGroup; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() private showBankAccount = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showPayPal = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showAccountCredit = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hasEnoughAccountCredit = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() includeBillingAddress = false; protected showBankAccount$!: Observable; diff --git a/apps/web/src/app/billing/payment/components/payment-label.component.ts b/apps/web/src/app/billing/payment/components/payment-label.component.ts index 8ecc7b7fd9e..5842235679c 100644 --- a/apps/web/src/app/billing/payment/components/payment-label.component.ts +++ b/apps/web/src/app/billing/payment/components/payment-label.component.ts @@ -11,6 +11,8 @@ import { SharedModule } from "../../../shared"; * * Applies the same label styles from CL form-field 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({ selector: "app-payment-label", template: ` @@ -32,8 +34,12 @@ import { SharedModule } from "../../../shared"; }) export class PaymentLabelComponent { /** `id` of the associated input */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) for: string; /** Displays required text on the label */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) required = false; constructor() {} diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts index b1ca1922775..3afd76e86ce 100644 --- a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts @@ -29,6 +29,8 @@ type DialogParams = { }; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts index cc1f1ab5e0a..98e8ba99e5e 100644 --- a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts @@ -14,8 +14,12 @@ export type SubmitPaymentMethodDialogResult = | { type: "error" } | { type: "success"; paymentMethod: MaskedPaymentMethod }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "" }) export abstract class SubmitPaymentMethodDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) private enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected formGroup = EnterPaymentMethodComponent.getFormGroup(); diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts index b1a2814daf2..5e61cf5b129 100644 --- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -9,6 +9,8 @@ import { SharedModule } from "../../../shared"; import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; +// 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-verify-bank-account", template: ` @@ -35,7 +37,11 @@ import { MaskedPaymentMethod } from "../types"; providers: [SubscriberBillingClient], }) export class VerifyBankAccountComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() verified = new EventEmitter(); protected formGroup = new FormGroup({ diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.ts b/apps/web/src/app/billing/settings/sponsored-families.component.ts index 80e66784ae8..530db0ff397 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.ts +++ b/apps/web/src/app/billing/settings/sponsored-families.component.ts @@ -33,6 +33,8 @@ interface RequestSponsorshipForm { sponsorshipEmail: FormControl; } +// 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-sponsored-families", templateUrl: "sponsored-families.component.html", diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts b/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts index 70320e7e62e..6d27130025d 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts @@ -15,15 +15,23 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DialogService, ToastService } from "@bitwarden/components"; +// 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: "[sponsoring-org-row]", templateUrl: "sponsoring-org-row.component.html", standalone: false, }) export class SponsoringOrgRowComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() sponsoringOrg: Organization = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() isSelfHosted = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() sponsorshipRemoved = new EventEmitter(); statusMessage = "loading"; diff --git a/apps/web/src/app/billing/shared/adjust-storage-dialog/adjust-storage-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-storage-dialog/adjust-storage-dialog.component.ts index 1f9172eaf59..a9857588e1c 100644 --- a/apps/web/src/app/billing/shared/adjust-storage-dialog/adjust-storage-dialog.component.ts +++ b/apps/web/src/app/billing/shared/adjust-storage-dialog/adjust-storage-dialog.component.ts @@ -29,6 +29,8 @@ export enum AdjustStorageDialogResultType { Closed = "closed", } +// 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: "./adjust-storage-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/billing/shared/billing-free-families-nav-item.component.ts b/apps/web/src/app/billing/shared/billing-free-families-nav-item.component.ts index 60b46c2b64e..00d4a7835e5 100644 --- a/apps/web/src/app/billing/shared/billing-free-families-nav-item.component.ts +++ b/apps/web/src/app/billing/shared/billing-free-families-nav-item.component.ts @@ -7,6 +7,8 @@ import { FreeFamiliesPolicyService } from "../services/free-families-policy.serv import { BillingSharedModule } from "./billing-shared.module"; +// 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: "billing-free-families-nav-item", templateUrl: "./billing-free-families-nav-item.component.html", diff --git a/apps/web/src/app/billing/shared/billing-history.component.ts b/apps/web/src/app/billing/shared/billing-history.component.ts index 745939f0d5e..a5d8d7e3da7 100644 --- a/apps/web/src/app/billing/shared/billing-history.component.ts +++ b/apps/web/src/app/billing/shared/billing-history.component.ts @@ -8,18 +8,26 @@ import { BillingTransactionResponse, } from "@bitwarden/common/billing/models/response/billing.response"; +// 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-billing-history", templateUrl: "billing-history.component.html", standalone: false, }) export class BillingHistoryComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() openInvoices: BillingInvoiceResponse[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() paidInvoices: BillingInvoiceResponse[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() transactions: BillingTransactionResponse[]; diff --git a/apps/web/src/app/billing/shared/offboarding-survey.component.ts b/apps/web/src/app/billing/shared/offboarding-survey.component.ts index 9f21f2b8cd5..fe7d724a079 100644 --- a/apps/web/src/app/billing/shared/offboarding-survey.component.ts +++ b/apps/web/src/app/billing/shared/offboarding-survey.component.ts @@ -46,6 +46,8 @@ export const openOffboardingSurvey = ( dialogConfig, ); +// 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-cancel-subscription-form", templateUrl: "offboarding-survey.component.html", diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts index 4150ddc25ba..0c64d078757 100644 --- a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts @@ -11,13 +11,15 @@ export interface PlanCard { productTier: ProductTierType; } +// 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-plan-card", templateUrl: "./plan-card.component.html", standalone: false, }) export class PlanCardComponent { - plan = input.required(); + readonly plan = input.required(); productTiers = ProductTierType; cardClicked = output(); diff --git a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts index d4fdf35b743..f502297425a 100644 --- a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts @@ -31,12 +31,16 @@ export interface PricingSummaryData { estimatedTax?: number; } +// 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-pricing-summary", templateUrl: "./pricing-summary.component.html", standalone: false, }) export class PricingSummaryComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() summaryData!: PricingSummaryData; planIntervals = PlanInterval; diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts index 75da10a7b09..8c4010d2117 100644 --- a/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts @@ -14,6 +14,8 @@ import { AbstractSelfHostingLicenseUploaderComponent } from "../../shared/self-h * Processes license file uploads for individual plans. * @remarks Requires self-hosting. */ +// 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: "individual-self-hosting-license-uploader", templateUrl: "./self-hosting-license-uploader.component.html", @@ -23,6 +25,8 @@ export class IndividualSelfHostingLicenseUploaderComponent extends AbstractSelfH /** * Emitted when a license file has been successfully uploaded & processed. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onLicenseFileUploaded: EventEmitter = new EventEmitter(); constructor( diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts index e2b43a6a568..892a42ef61c 100644 --- a/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts @@ -24,6 +24,8 @@ import { AbstractSelfHostingLicenseUploaderComponent } from "../../shared/self-h * Processes license file uploads for organizations. * @remarks Requires self-hosting. */ +// 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: "organization-self-hosting-license-uploader", templateUrl: "./self-hosting-license-uploader.component.html", @@ -33,6 +35,8 @@ export class OrganizationSelfHostingLicenseUploaderComponent extends AbstractSel /** * Notifies the parent component of the `organizationId` the license was created for. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onLicenseFileUploaded: EventEmitter = new EventEmitter(); constructor( diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.ts b/apps/web/src/app/billing/shared/sm-subscribe.component.ts index d1e5566a235..739cc6f1451 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.ts +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.ts @@ -29,16 +29,28 @@ export const secretsManagerSubscribeFormFactory = ( ], }); +// 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: "sm-subscribe", templateUrl: "sm-subscribe.component.html", standalone: false, }) export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() formGroup: FormGroup>; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() upgradeOrganization: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showSubmitButton = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() selectedPlan: PlanResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() customerDiscount: BillingCustomerDiscount; logo = SecretsManagerAlt; diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts index ed59e2a2d97..64af7be948e 100644 --- a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts @@ -67,6 +67,8 @@ interface OnSuccessArgs { organizationId: string; } +// 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-trial-payment-dialog", templateUrl: "./trial-payment-dialog.component.html", @@ -74,6 +76,8 @@ interface OnSuccessArgs { providers: [SubscriberBillingClient, TaxClient], }) export class TrialPaymentDialogComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; currentPlan!: PlanResponse; @@ -84,9 +88,11 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy { sub!: OrganizationSubscriptionResponse; selectedInterval: PlanInterval = PlanInterval.Annually; - planCards = signal([]); + readonly planCards = signal([]); plans!: ListResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccess = new EventEmitter(); protected initialPaymentMethod: PaymentMethodType; protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE; diff --git a/apps/web/src/app/billing/shared/update-license-dialog.component.ts b/apps/web/src/app/billing/shared/update-license-dialog.component.ts index 11b5e7fd8df..d9c885c9819 100644 --- a/apps/web/src/app/billing/shared/update-license-dialog.component.ts +++ b/apps/web/src/app/billing/shared/update-license-dialog.component.ts @@ -10,6 +10,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { UpdateLicenseDialogResult } from "./update-license-types"; import { UpdateLicenseComponent } from "./update-license.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: "update-license-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/billing/shared/update-license.component.ts b/apps/web/src/app/billing/shared/update-license.component.ts index 455b38386c6..fa42c116184 100644 --- a/apps/web/src/app/billing/shared/update-license.component.ts +++ b/apps/web/src/app/billing/shared/update-license.component.ts @@ -12,16 +12,28 @@ import { ToastService } from "@bitwarden/components"; import { UpdateLicenseDialogResult } from "./update-license-types"; +// 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-update-license", templateUrl: "update-license.component.html", standalone: false, }) export class UpdateLicenseComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showCancel = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showAutomaticSyncAndManualUpload: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onUpdated = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); formPromise: Promise; diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index baccabdc763..19fa023a5b2 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -40,12 +40,16 @@ export type InitiationPath = | "Password Manager trial from marketing website" | "Secrets Manager trial from marketing website"; +// 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-complete-trial-initiation", templateUrl: "complete-trial-initiation.component.html", standalone: false, }) export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("stepper", { static: false }) verticalStepper!: VerticalStepperComponent; inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration; diff --git a/apps/web/src/app/billing/trial-initiation/confirmation-details.component.ts b/apps/web/src/app/billing/trial-initiation/confirmation-details.component.ts index cbb1c84284c..3c92749dd38 100644 --- a/apps/web/src/app/billing/trial-initiation/confirmation-details.component.ts +++ b/apps/web/src/app/billing/trial-initiation/confirmation-details.component.ts @@ -4,15 +4,25 @@ import { Component, Input } from "@angular/core"; import { ProductType } from "@bitwarden/common/billing/enums"; +// 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-trial-confirmation-details", templateUrl: "confirmation-details.component.html", standalone: false, }) export class ConfirmationDetailsComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() email: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() orgLabel: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() product?: ProductType = ProductType.PasswordManager; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() trialLength: number; protected readonly Product = ProductType; diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts index 0f185564c2e..04ee7931cf3 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts @@ -35,6 +35,8 @@ export interface OrganizationCreatedEvent { planDescription: string; } +// 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-trial-billing-step", templateUrl: "./trial-billing-step.component.html", @@ -42,8 +44,12 @@ export interface OrganizationCreatedEvent { providers: [TaxClient, TrialBillingStepService], }) export class TrialBillingStepComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected trial = input.required(); protected steppedBack = output(); protected organizationCreated = output(); diff --git a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step-content.component.ts b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step-content.component.ts index 0c6e084f5c4..183346b9033 100644 --- a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step-content.component.ts +++ b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step-content.component.ts @@ -4,17 +4,29 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { VerticalStep } from "./vertical-step.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({ selector: "app-vertical-step-content", templateUrl: "vertical-step-content.component.html", standalone: false, }) export class VerticalStepContentComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSelectStep = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disabled = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() selected = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() step: VerticalStep; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() stepNumber: number; selectStep() { diff --git a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step.component.ts b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step.component.ts index b4b643b3889..efd0f68e5d1 100644 --- a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step.component.ts +++ b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-step.component.ts @@ -1,6 +1,8 @@ import { CdkStep } from "@angular/cdk/stepper"; import { Component, Input } from "@angular/core"; +// 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-vertical-step", templateUrl: "vertical-step.component.html", @@ -8,7 +10,13 @@ import { Component, Input } from "@angular/core"; standalone: false, }) export class VerticalStep extends CdkStep { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() subLabel = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() applyBorder = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() addSubLabelSpacing = false; } diff --git a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-stepper.component.ts b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-stepper.component.ts index 333224aac54..c7c2c17000e 100644 --- a/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-stepper.component.ts +++ b/apps/web/src/app/billing/trial-initiation/vertical-stepper/vertical-stepper.component.ts @@ -5,6 +5,8 @@ import { Component, Input, QueryList } from "@angular/core"; import { VerticalStep } from "./vertical-step.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({ selector: "app-vertical-stepper", templateUrl: "vertical-stepper.component.html", @@ -14,6 +16,8 @@ import { VerticalStep } from "./vertical-step.component"; export class VerticalStepperComponent extends CdkStepper { readonly steps: QueryList; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeClass = "active"; diff --git a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts index 55fa0c0f439..c0fe5626fcb 100644 --- a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts +++ b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts @@ -83,6 +83,8 @@ type View = { type GetWarning$ = () => Observable; +// 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-tax-id-warning", template: ` @@ -108,8 +110,14 @@ type GetWarning$ = () => Observable; imports: [BannerModule, SharedModule], }) export class TaxIdWarningComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: NonIndividualSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) getWarning$!: GetWarning$; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() billingAddressUpdated = new EventEmitter(); protected enableTaxIdWarning$ = this.configService.getFeatureFlag$( diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts index a99d86b6e96..e36e4e5f0c6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts @@ -25,6 +25,8 @@ export enum AddExistingOrganizationDialogResultType { Submitted = "submitted", } +// 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: "./add-existing-organization-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/create-client-dialog.component.ts index 73e642dfa06..917ccf58e46 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/create-client-dialog.component.ts @@ -100,6 +100,8 @@ export class PlanCard { } } +// 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: "./create-client-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-name-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-name-dialog.component.ts index 045c9d8e8df..7e093fdad9b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-name-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-name-dialog.component.ts @@ -39,6 +39,8 @@ export const openManageClientNameDialog = ( dialogConfig, ); +// 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: "manage-client-name-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-subscription-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-subscription-dialog.component.ts index 4c80402d3f7..9e74a91a4c0 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-subscription-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-client-subscription-dialog.component.ts @@ -35,6 +35,8 @@ export const openManageClientSubscriptionDialog = ( ManageClientSubscriptionDialogParams >(ManageClientSubscriptionDialogComponent, dialogConfig); +// 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: "./manage-client-subscription-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts index a3601d2c812..eed3db87396 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts @@ -57,6 +57,8 @@ import { import { NoClientsComponent } from "./no-clients.component"; import { ReplacePipe } from "./replace.pipe"; +// 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: "manage-clients.component.html", imports: [ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/no-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/no-clients.component.ts index ed11eb8ef0a..f78e8ae38f2 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/no-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/no-clients.component.ts @@ -4,6 +4,8 @@ import { GearIcon } from "@bitwarden/assets/svg"; import { NoItemsModule } from "@bitwarden/components"; import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; +// 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-no-clients", imports: [SharedOrganizationModule, NoItemsModule], @@ -27,8 +29,14 @@ import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console }) export class NoClientsComponent { icon = GearIcon; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showAddOrganizationButton = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disableAddOrganizationButton = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() addNewOrganizationClicked = new EventEmitter(); addNewOrganization = () => this.addNewOrganizationClicked.emit(); diff --git a/bitwarden_license/bit-web/src/app/billing/policies/free-families-sponsorship.component.ts b/bitwarden_license/bit-web/src/app/billing/policies/free-families-sponsorship.component.ts index db5ef3ba62f..d1c6c820547 100644 --- a/bitwarden_license/bit-web/src/app/billing/policies/free-families-sponsorship.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/policies/free-families-sponsorship.component.ts @@ -14,6 +14,8 @@ export class FreeFamiliesSponsorshipPolicy extends BasePolicyEditDefinition { component = FreeFamiliesSponsorshipPolicyComponent; } +// 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: "free-families-sponsorship.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts index fc3352048d6..6c607d205b6 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts @@ -8,15 +8,25 @@ import { } from "@bitwarden/common/billing/models/response/invoices.response"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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-invoices", templateUrl: "./invoices.component.html", standalone: false, }) export class InvoicesComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() startWith?: InvoicesResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() getInvoices?: () => Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() getClientInvoiceReport?: (invoiceId: string) => Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() getClientInvoiceReportName?: (invoiceResponse: InvoiceResponse) => string; protected invoices: InvoiceResponse[] = []; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts index ded6bc79593..882a2c764ac 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { CreditCardIcon } from "@bitwarden/assets/svg"; +// 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-no-invoices", template: ` diff --git a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts index d1a9d43a6fc..5823080bd3b 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/provider-billing-history.component.ts @@ -10,6 +10,8 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract import { InvoiceResponse } from "@bitwarden/common/billing/models/response/invoices.response"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.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({ templateUrl: "./provider-billing-history.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts index 5a070687de4..183e6098471 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts @@ -59,6 +59,8 @@ const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{ adminId: string; }>("providerBankAccountVerified"); +// 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: "./provider-payment-details.component.html", imports: [ diff --git a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts index a3f8acd6488..4b8dfce05d5 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts @@ -17,6 +17,8 @@ import { KeyService } from "@bitwarden/key-management"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.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: "./setup-business-unit.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts index f9ff006de24..dfbfdb29eef 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts @@ -23,12 +23,16 @@ type ComponentData = { }; }; +// 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-provider-subscription-status", templateUrl: "provider-subscription-status.component.html", standalone: false, }) export class ProviderSubscriptionStatusComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscription: ProviderSubscriptionResponse; constructor( diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts index 98aceb0f878..2e43ce966d3 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts @@ -11,6 +11,8 @@ import { } from "@bitwarden/common/billing/models/response/provider-subscription-response"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.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-provider-subscription", templateUrl: "./provider-subscription.component.html", diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index a4a1d76d1d6..e8a829d458d 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -4,6 +4,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule } from "@bitwarden/components"; +// 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-premium-badge", standalone: true, @@ -15,7 +17,7 @@ import { BadgeModule } from "@bitwarden/components"; imports: [BadgeModule, JslibModule], }) export class PremiumBadgeComponent { - organizationId = input(); + readonly organizationId = input(); constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b21276b5038..11c6cddcab1 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -16,6 +16,8 @@ export type LineItem = { * This component has no external dependencies and performs minimal logic - * it only displays data and allows expanding/collapsing of line items. */ +// 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: "billing-cart-summary", templateUrl: "./cart-summary.component.html", @@ -23,13 +25,13 @@ export type LineItem = { }) export class CartSummaryComponent { // Required inputs - passwordManager = input.required(); - additionalStorage = input(); - secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>(); - estimatedTax = input.required(); + readonly passwordManager = input.required(); + readonly additionalStorage = input(); + readonly secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>(); + readonly estimatedTax = input.required(); // UI state - isExpanded = signal(true); + readonly isExpanded = signal(true); /** * Calculates total for password manager line item diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index ed2c28d8cb3..df60d7647f7 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -6,6 +6,8 @@ import { ButtonType, IconModule, TypographyModule } from "@bitwarden/components" import { PricingCardComponent } from "./pricing-card.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({ template: ` (); readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() buttonClick = new EventEmitter(); /** From 2eef32d757615d48283360aabd578448575c1f10 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 23 Oct 2025 17:21:48 -0400 Subject: [PATCH 32/35] fix(billing): add condition to disable submit button for account credit (#17006) --- .../upgrade/upgrade-payment/upgrade-payment.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 2228a6f6c06..9b007ae7a6b 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -70,7 +70,7 @@ bitButton bitFormButton buttonType="primary" - [disabled]="loading() || !isFormValid()" + [disabled]="loading() || !isFormValid() || !(hasEnoughAccountCredit$ | async)" type="submit" > {{ "upgrade" | i18n }} From ce84d2f117eb6d57b55733c3d3279256f673d8d4 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:02:37 -0700 Subject: [PATCH 33/35] fix(sso-config): (Auth) [PM-27244] Refactor KC URL Handling (#16995) Addresses some bugs with the Key Connector URL form field. --- .../src/app/auth/sso/sso.component.html | 4 +- .../bit-web/src/app/auth/sso/sso.component.ts | 216 +++++++++++------- 2 files changed, 133 insertions(+), 87 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index 6d2836ee0ba..db2e000246b 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -1,7 +1,7 @@ - + {{ "loading" | i18n }} - +

{{ "ssoPolicyHelpStart" | i18n }} {{ "ssoPolicyHelpAnchor" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index 4928d7a6abc..1c25283ea4f 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -9,15 +9,7 @@ import { Validators, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { - concatMap, - firstValueFrom, - pairwise, - startWith, - Subject, - switchMap, - takeUntil, -} from "rxjs"; +import { concatMap, firstValueFrom, Subject, Subscription, switchMap, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -45,8 +37,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; import { ssoTypeValidator } from "./sso-type.validator"; @@ -120,7 +114,11 @@ export class SsoComponent implements OnInit, OnDestroy { showOpenIdCustomizations = false; - loading = true; + isInitializing = true; // concerned with UI/UX (i.e. when to show loading spinner vs form) + isFormValidatingOrPopulating = true; // tracks when form fields are being validated/populated during load() or submit() + + configuredKeyConnectorUrlFromServer: string | null; + memberDecryptionTypeValueChangesSubscription: Subscription | null = null; haveTestedKeyConnector = false; organizationId: string; organization: Organization; @@ -215,6 +213,8 @@ export class SsoComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private toastService: ToastService, private environmentService: EnvironmentService, + private validationService: ValidationService, + private logService: LogService, ) {} async ngOnInit() { @@ -265,41 +265,6 @@ export class SsoComponent implements OnInit, OnDestroy { .subscribe(); this.showKeyConnectorOptions = this.platformUtilsService.isSelfHost(); - - // Only setup listener if key connector is a possible selection - if (this.showKeyConnectorOptions) { - this.listenForKeyConnectorSelection(); - } - } - - listenForKeyConnectorSelection() { - const memberDecryptionTypeOnInit = this.ssoConfigForm?.controls?.memberDecryptionType.value; - - this.ssoConfigForm?.controls?.memberDecryptionType.valueChanges - .pipe( - startWith(memberDecryptionTypeOnInit), - pairwise(), - switchMap(async ([prevMemberDecryptionType, newMemberDecryptionType]) => { - // Only pre-populate a default URL when changing TO Key Connector from a different decryption type. - // ValueChanges gets re-triggered during the submit() call, so we need a !== check - // to prevent a custom URL from getting overwritten back to the default on a submit(). - if ( - prevMemberDecryptionType !== MemberDecryptionType.KeyConnector && - newMemberDecryptionType === MemberDecryptionType.KeyConnector - ) { - // Pre-populate a default key connector URL (user can still change it) - const env = await firstValueFrom(this.environmentService.environment$); - const webVaultUrl = env.getWebVaultUrl(); - const defaultKeyConnectorUrl = webVaultUrl + "/key-connector"; - - this.ssoConfigForm.controls.keyConnectorUrl.setValue(defaultKeyConnectorUrl); - } else if (newMemberDecryptionType !== MemberDecryptionType.KeyConnector) { - this.ssoConfigForm.controls.keyConnectorUrl.setValue(""); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); } ngOnDestroy(): void { @@ -308,55 +273,135 @@ export class SsoComponent implements OnInit, OnDestroy { } async load() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.organization = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - const ssoSettings = await this.organizationApiService.getSso(this.organizationId); - this.populateForm(ssoSettings); + // Even though these component properties were initialized to true, we must always reset + // them to true at the top of this method in case an admin navigates to another org via + // the browser address bar, which re-executes load() on the same component instance + // (not a new instance). + this.isInitializing = true; + this.isFormValidatingOrPopulating = true; + // Same with unsubscribing: re-executing load() on the same component instance (not a new + // instance) means we will not unsubscribe via takeUntil(this.destroy$). We must manually + // unsubscribe for this case. We unsubscribe here in case the try block fails. + this.memberDecryptionTypeValueChangesSubscription?.unsubscribe(); + this.memberDecryptionTypeValueChangesSubscription = null; - this.callbackPath = ssoSettings.urls.callbackPath; - this.signedOutCallbackPath = ssoSettings.urls.signedOutCallbackPath; - this.spEntityId = ssoSettings.urls.spEntityId; - this.spEntityIdStatic = ssoSettings.urls.spEntityIdStatic; - this.spMetadataUrl = ssoSettings.urls.spMetadataUrl; - this.spAcsUrl = ssoSettings.urls.spAcsUrl; + try { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.organization = await firstValueFrom( + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.organizationId)), + ); + const ssoSettings = await this.organizationApiService.getSso(this.organizationId); + this.configuredKeyConnectorUrlFromServer = ssoSettings.data?.keyConnectorUrl; + this.populateForm(ssoSettings); - this.loading = false; + this.callbackPath = ssoSettings.urls.callbackPath; + this.signedOutCallbackPath = ssoSettings.urls.signedOutCallbackPath; + this.spEntityId = ssoSettings.urls.spEntityId; + this.spEntityIdStatic = ssoSettings.urls.spEntityIdStatic; + this.spMetadataUrl = ssoSettings.urls.spMetadataUrl; + this.spAcsUrl = ssoSettings.urls.spAcsUrl; + + if (this.showKeyConnectorOptions) { + // We don't setup this subscription until AFTER the form has been populated on load(). + // This is because populateForm() will trigger valueChanges, but we don't want to + // listen for or react to valueChanges until AFTER the form has had a chance to be + // populated with already configured values retrieved from the server. + this.subscribeToMemberDecryptionTypeValueChanges(); + } + } catch (error) { + this.logService.error("Error loading SSO configuration: ", error); + this.validationService.showError(error); + } finally { + this.isInitializing = false; + this.isFormValidatingOrPopulating = false; + } } submit = async () => { - this.updateFormValidationState(this.ssoConfigForm); + this.isFormValidatingOrPopulating = true; - if (this.ssoConfigForm.value.memberDecryptionType === MemberDecryptionType.KeyConnector) { - this.haveTestedKeyConnector = false; - await this.validateKeyConnectorUrl(); + try { + this.updateFormValidationState(this.ssoConfigForm); + + if (this.ssoConfigForm.value.memberDecryptionType === MemberDecryptionType.KeyConnector) { + this.haveTestedKeyConnector = false; + await this.validateKeyConnectorUrl(); + } + + if (!this.ssoConfigForm.valid) { + this.readOutErrors(); + return; + } + const request = new OrganizationSsoRequest(); + request.enabled = this.enabledCtrl.value; + // Return null instead of empty string to avoid duplicate id errors in database + request.identifier = + this.ssoIdentifierCtrl.value === "" ? null : this.ssoIdentifierCtrl.value; + request.data = SsoConfigApi.fromView(this.ssoConfigForm.getRawValue()); + + const response = await this.organizationApiService.updateSso(this.organizationId, request); + this.configuredKeyConnectorUrlFromServer = response.data?.keyConnectorUrl; + this.populateForm(response); + + await this.upsertOrganizationWithSsoChanges(request); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("ssoSettingsSaved"), + }); + } finally { + this.isFormValidatingOrPopulating = false; } - - if (!this.ssoConfigForm.valid) { - this.readOutErrors(); - return; - } - const request = new OrganizationSsoRequest(); - request.enabled = this.enabledCtrl.value; - // Return null instead of empty string to avoid duplicate id errors in database - request.identifier = this.ssoIdentifierCtrl.value === "" ? null : this.ssoIdentifierCtrl.value; - request.data = SsoConfigApi.fromView(this.ssoConfigForm.getRawValue()); - - const response = await this.organizationApiService.updateSso(this.organizationId, request); - this.populateForm(response); - - await this.upsertOrganizationWithSsoChanges(request); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("ssoSettingsSaved"), - }); }; + private subscribeToMemberDecryptionTypeValueChanges() { + // The load() method will have unsubscribed from any pre-existing subscription before + // we setup a new subscription here. + + this.memberDecryptionTypeValueChangesSubscription = + this.ssoConfigForm?.controls?.memberDecryptionType.valueChanges + .pipe( + switchMap(async (memberDecryptionType: MemberDecryptionType) => { + this.haveTestedKeyConnector = false; + + if (this.isFormValidatingOrPopulating) { + // If the form is being validated/populated due to a load() or submit() call (both of which + // trigger valueChanges) we don't want to react to this valueChanges emission. + return; + } + + if (memberDecryptionType === MemberDecryptionType.KeyConnector) { + if (this.configuredKeyConnectorUrlFromServer) { + // If the user already has a key connector URL configured, it will have been retrieved + // from the server and set to the form field upon load(). But if this user then selects a + // different Member Decryption option (but does not save the form), and then once again + // selects the Key Connector option, we want to pre-populate the form field with the already + // configured URL that was originally retreived from the server, not a default URL. + this.ssoConfigForm.controls.keyConnectorUrl.setValue( + this.configuredKeyConnectorUrlFromServer, + ); + return; + } + + // Pre-populate a default key connector URL (user can still change it) + const env = await firstValueFrom(this.environmentService.environment$); + const webVaultUrl = env.getWebVaultUrl(); + const defaultKeyConnectorUrl = webVaultUrl + "/key-connector"; + + this.ssoConfigForm.controls.keyConnectorUrl.setValue(defaultKeyConnectorUrl); + } else { + // Clear the key connector url + this.ssoConfigForm.controls.keyConnectorUrl.setValue(""); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + async validateKeyConnectorUrl() { if (this.haveTestedKeyConnector) { return; @@ -371,6 +416,7 @@ export class SsoComponent implements OnInit, OnDestroy { this.keyConnectorUrl.setErrors({ invalidUrl: { message: this.i18nService.t("keyConnectorTestFail") }, }); + this.keyConnectorUrl.markAllAsTouched(); } this.haveTestedKeyConnector = true; From 1b685e3b7e367f271f4e985056e80dec210d6e7d Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 08:45:13 +0200 Subject: [PATCH 34/35] Autosync the updated translations (#17010) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 3 + apps/browser/src/_locales/az/messages.json | 3 + apps/browser/src/_locales/be/messages.json | 3 + apps/browser/src/_locales/bg/messages.json | 3 + apps/browser/src/_locales/bn/messages.json | 5 +- apps/browser/src/_locales/bs/messages.json | 3 + apps/browser/src/_locales/ca/messages.json | 55 ++++++++++--------- apps/browser/src/_locales/cs/messages.json | 3 + apps/browser/src/_locales/cy/messages.json | 3 + apps/browser/src/_locales/da/messages.json | 3 + apps/browser/src/_locales/de/messages.json | 3 + apps/browser/src/_locales/el/messages.json | 3 + apps/browser/src/_locales/en_GB/messages.json | 3 + apps/browser/src/_locales/en_IN/messages.json | 3 + apps/browser/src/_locales/es/messages.json | 3 + apps/browser/src/_locales/et/messages.json | 3 + apps/browser/src/_locales/eu/messages.json | 3 + apps/browser/src/_locales/fa/messages.json | 3 + apps/browser/src/_locales/fi/messages.json | 3 + apps/browser/src/_locales/fil/messages.json | 3 + apps/browser/src/_locales/fr/messages.json | 3 + apps/browser/src/_locales/gl/messages.json | 3 + apps/browser/src/_locales/he/messages.json | 3 + apps/browser/src/_locales/hi/messages.json | 3 + apps/browser/src/_locales/hr/messages.json | 27 +++++---- apps/browser/src/_locales/hu/messages.json | 3 + apps/browser/src/_locales/id/messages.json | 3 + apps/browser/src/_locales/it/messages.json | 23 ++++---- apps/browser/src/_locales/ja/messages.json | 3 + apps/browser/src/_locales/ka/messages.json | 3 + apps/browser/src/_locales/km/messages.json | 3 + apps/browser/src/_locales/kn/messages.json | 3 + apps/browser/src/_locales/ko/messages.json | 41 +++++++------- apps/browser/src/_locales/lt/messages.json | 3 + apps/browser/src/_locales/lv/messages.json | 3 + apps/browser/src/_locales/ml/messages.json | 3 + apps/browser/src/_locales/mr/messages.json | 3 + apps/browser/src/_locales/my/messages.json | 3 + apps/browser/src/_locales/nb/messages.json | 3 + apps/browser/src/_locales/ne/messages.json | 3 + apps/browser/src/_locales/nl/messages.json | 3 + apps/browser/src/_locales/nn/messages.json | 3 + apps/browser/src/_locales/or/messages.json | 3 + apps/browser/src/_locales/pl/messages.json | 3 + apps/browser/src/_locales/pt_BR/messages.json | 3 + apps/browser/src/_locales/pt_PT/messages.json | 3 + apps/browser/src/_locales/ro/messages.json | 3 + apps/browser/src/_locales/ru/messages.json | 3 + apps/browser/src/_locales/si/messages.json | 3 + apps/browser/src/_locales/sk/messages.json | 3 + apps/browser/src/_locales/sl/messages.json | 3 + apps/browser/src/_locales/sr/messages.json | 3 + apps/browser/src/_locales/sv/messages.json | 3 + apps/browser/src/_locales/ta/messages.json | 3 + apps/browser/src/_locales/te/messages.json | 3 + apps/browser/src/_locales/th/messages.json | 3 + apps/browser/src/_locales/tr/messages.json | 3 + apps/browser/src/_locales/uk/messages.json | 3 + apps/browser/src/_locales/vi/messages.json | 3 + apps/browser/src/_locales/zh_CN/messages.json | 3 + apps/browser/src/_locales/zh_TW/messages.json | 5 +- 61 files changed, 252 insertions(+), 69 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 10443fcf449..35d21b59be9 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/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/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index ad44440a343..2c9a496a95c 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Key Connector domenini təsdiqlə" }, + "atRiskLoginsSecured": { + "message": "Riskli girişlərinizi güvənli hala gətirməyiniz əladır!" + }, "settingDisabledByPolicy": { "message": "Bu ayar, təşkilatınızın siyasəti tərəfindən sıradan çıxarılıb.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index e9ec6a06b8c..f9fd41cf6e7 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/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/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 942a2f489a0..d8c288d9fca 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Потвърждаване на домейна на конектора за ключове" }, + "atRiskLoginsSecured": { + "message": "Добра работа с подсигуряването на данните за вписване в риск!" + }, "settingDisabledByPolicy": { "message": "Тази настройка е изключена съгласно политиката на организацията Ви.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 8b8b89d45a2..1b8c289f717 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -23,7 +23,7 @@ "message": "অ্যাকাউন্ট তৈরি করুন" }, "newToBitwarden": { - "message": "New to Bitwarden?" + "message": "বিটওয়ার্ডেনে নতুন?" }, "logInWithPasskey": { "message": "Log in with passkey" @@ -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/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 914b8700d13..8cc0d947199 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/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/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 8f3a0ca386d..4483967ab33 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -320,7 +320,7 @@ "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "twoStepLogin": { - "message": "Inici de sessió en dues passes" + "message": "Inici de sessió en dos passos" }, "logOut": { "message": "Tanca la sessió" @@ -970,7 +970,7 @@ "message": "Carpeta afegida" }, "twoStepLoginConfirmation": { - "message": "L'inici de sessió en dues passes fa que el vostre compte siga més segur, ja que obliga a verificar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dues passes a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" + "message": "L'inici de sessió en dos passos fa que el vostre compte siga més segur, ja que obliga a verificar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dos passos a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" }, "twoStepLoginConfirmationContent": { "message": "Fes que el vostre compte siga més segur configurant l'inici de sessió en dos passos a l'aplicació web de Bitwarden." @@ -1564,13 +1564,13 @@ "message": "Inici de sessió no disponible" }, "noTwoStepProviders": { - "message": "Aquest compte té habilitat l'inici de sessió en dues passes, però aquest navegador web no admet cap dels dos proveïdors configurats." + "message": "Aquest compte té habilitat l'inici de sessió en dos passos, però aquest navegador web no admet cap dels dos proveïdors configurats." }, "noTwoStepProviders2": { "message": "Utilitzeu un navegador web compatible (com ara Chrome) o afegiu proveïdors addicionals que siguen compatibles amb tots els navegadors web (com una aplicació d'autenticació)." }, "twoStepOptions": { - "message": "Opcions d'inici de sessió en dues passes" + "message": "Opcions d'inici de sessió en dos passos" }, "selectTwoStepLoginMethod": { "message": "Select two-step login method" @@ -1659,13 +1659,13 @@ "message": "Suggeriments d'emplenament automàtic" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "Trobeu fàcilment suggeriments d'emplenament automàtic" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Desactiveu la configuració d'emplenament automàtic del vostre navegador perquè no entren en conflicte amb Bitwarden." }, "turnOffBrowserAutofill": { - "message": "Turn off $BROWSER$ autofill", + "message": "Desactiveu l'emplenament automàtic de $BROWSER$", "placeholders": { "browser": { "content": "$1", @@ -1805,7 +1805,7 @@ "message": "Si feu clic a l'exterior de la finestra emergent per comprovar el vostre correu electrònic amb el codi de verificació, es tancarà aquesta finestra. Voleu obrir aquesta finestra emergent en una finestra nova perquè no es tanque?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostra les icones del lloc web i recupera els URL de canvi de contrasenya" }, "cardholderName": { "message": "Nom del titular de la targeta" @@ -3681,7 +3681,7 @@ "message": "Remember this device to make future logins seamless" }, "manageDevices": { - "message": "Manage devices" + "message": "Gestiona els dispositius" }, "currentSession": { "message": "Current session" @@ -3724,7 +3724,7 @@ "message": "Needs approval" }, "devices": { - "message": "Devices" + "message": "Dispositius" }, "accessAttemptBy": { "message": "Access attempt by $EMAIL$", @@ -4813,22 +4813,22 @@ "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Baixa Bitwarden a tots els dispositius" }, "getTheMobileApp": { "message": "Get the mobile app" }, "getTheMobileAppDesc": { - "message": "Access your passwords on the go with the Bitwarden mobile app." + "message": "Accediu a les vostres contrasenyes des de qualsevol lloc amb l'aplicació mòbil Bitwarden." }, "getTheDesktopApp": { - "message": "Get the desktop app" + "message": "Obteniu l'aplicació d'escriptori" }, "getTheDesktopAppDesc": { - "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." + "message": "Accediu a la vostra caixa forta sense navegador i, a continuació, configureu el desbloqueig amb biometria per accelerar el desbloqueig tant a l'aplicació d'escriptori com a l'extensió del navegador." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "Baixeu ara des de bitwarden.com" }, "getItOnGooglePlay": { "message": "Get it on Google Play" @@ -5298,10 +5298,10 @@ "message": "Biometric unlock is currently unavailable for an unknown reason." }, "unlockVault": { - "message": "Unlock your vault in seconds" + "message": "Desbloqueja la caixa forta en segons" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "Podeu personalitzar la configuració de desbloqueig i temps d'espera per accedir més ràpidament a la vostra caixa forta." }, "unlockPinSet": { "message": "Unlock PIN set" @@ -5538,7 +5538,7 @@ "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." }, "introCarouselLabel": { - "message": "Welcome to Bitwarden" + "message": "Benvinguts a Bitwarden" }, "securityPrioritized": { "message": "Security, prioritized" @@ -5577,7 +5577,7 @@ "message": "Import now" }, "hasItemsVaultNudgeTitle": { - "message": "Welcome to your vault!" + "message": "Benvigut/da a la vostra caixa forta!" }, "phishingPageTitleV2": { "message": "Phishing attempt detected" @@ -5612,13 +5612,13 @@ } }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Emplena automàticament els elements de la pàgina actual" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Elements preferits per accedir fàcilment" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Cerca altres coses a la caixa forta" }, "newLoginNudgeTitle": { "message": "Save time with autofill" @@ -5670,20 +5670,20 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Creeu contrasenyes ràpidament" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "Creeu fàcilment contrasenyes fortes i úniques fent clic a", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "per ajudar-vos a mantenir segurs els vostres inicis de sessió.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "Creeu fàcilment contrasenyes fortes i úniques fent clic al botó Genera contrasenya per ajudar-vos a mantenir segurs els vostres inicis de sessió.", "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { @@ -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/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index db522f3aa4e..b9383416eb4 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Potvrdit doménu Key Connectoru" }, + "atRiskLoginsSecured": { + "message": "Skvělá práce při zabezpečení přihlašovacích údajů v ohrožení!" + }, "settingDisabledByPolicy": { "message": "Toto nastavení je zakázáno zásadami Vaší organizace.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index eacbb06fd53..c18633c281c 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/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/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index ddc6f33599f..0f92552c9c1 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/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/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8878f4b698e..411b73be447 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Key Connector-Domain bestätigen" }, + "atRiskLoginsSecured": { + "message": "Gute Arbeit! Du hast deine gefährdeten Zugangsdaten geschützt!" + }, "settingDisabledByPolicy": { "message": "Diese Einstellung ist durch die Richtlinien deiner Organisation deaktiviert.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 7f519130df0..025a66c5cde 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/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/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1b78e39ecf8..7fd3091ef75 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/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 organisation'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/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index e7c3a197c75..88b95533ff1 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/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 organisation'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/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index cc3242fd4a9..2adf87d63f3 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/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/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 96adaeba324..1500e20e3aa 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/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/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index ab1d3f8ef8e..81106464f69 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/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/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 8d76ec3f428..6617ad085cc 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/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/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index b782c7e11af..57a6ecfedd0 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/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/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 6b85bdf8f43..88b94d9b9c1 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/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/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index a4518b54afc..15d1cdecacf 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Confirmez le domaine de Key Connector" }, + "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/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 66f459d97b7..137576cfb1f 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/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/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 5243fa03283..2164d197b0e 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "אשר דומיין של Key Connector" }, + "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/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index e887f573ba9..bc36073156b 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/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/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index a4441a9a142..e678f506387 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -559,7 +559,7 @@ "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Poništi arhiviranje" }, "itemsInArchive": { "message": "Stavke u arhivi" @@ -571,10 +571,10 @@ "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Stavka poslana u arhivu" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "Stavka vraćena iz arhive" }, "archiveItem": { "message": "Arhiviraj stavku" @@ -5580,30 +5580,30 @@ "message": "Dobrodošli u svoj trezor!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Otkriven pokušaj phishinga" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Web-mjesto koje pokušavaš posjetiti poznato je kao zlonamjerno i predstavlja sigurnosni rizik." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Zatvori ovu karticu" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Nastavi na web mjesto (nije preporučljivo)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Ovo mjesto je nađeno na ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": ", popisu otvorenog koda poznatih phishing stranica koje se koriste za krađu osobnih i osjetljivih podataka.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Saznaj više o otkrivanju phishinga" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Zaštićeno s $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5715,8 +5715,11 @@ "confirmKeyConnectorDomain": { "message": "Potvrdi domenu kontektora ključa" }, + "atRiskLoginsSecured": { + "message": "Rizične prijave su osigurane!" + }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Ova je postavka onemogućena pravilima tvoje organizacije.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 2f3d46f78d8..e2674595f4b 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "A Key Connector tartomány megerősítése" }, + "atRiskLoginsSecured": { + "message": "Remek munka a kockázatos bejelentkezések biztosítása!" + }, "settingDisabledByPolicy": { "message": "Ezt a beállítást a szervezet házirendje letiltotta.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index ccd332b9c1b..a5757e38caf 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/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/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 10b1c678826..233ae413e5f 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -574,7 +574,7 @@ "message": "Elemento archiviato" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "Elemento rimosso dall'archivio" }, "archiveItem": { "message": "Archivia elemento" @@ -5580,30 +5580,30 @@ "message": "Benvenuto nella tua cassaforte!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Tentativo di phishing rilevato" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Stai cercando di visitare un sito dannoso noto che può mettere a rischio la tua sicurezza." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Chiudi tab" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Vai al sito (SCONSIGLIATO!)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Questo sito è stato trovato in ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": ", un elenco open-source di siti di phishing noti per il furto di informazioni personali e sensibili.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Scopri di più sul rilevamento di phishing" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Protetto da $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5715,8 +5715,11 @@ "confirmKeyConnectorDomain": { "message": "Conferma dominio Key Connector" }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Questa impostazione è disabilitata dalle restrizioni della tua organizzazione.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 7c9d9e80ed4..4ab3cdc9c1b 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/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/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 3b3189acd6d..4ea5ab3390a 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/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/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/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/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 42a5c4f1b05..271db811810 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/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/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index b17055c72d0..c45532076da 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -189,7 +189,7 @@ "message": "노트 복사" }, "copy": { - "message": "Copy", + "message": "복사", "description": "Copy to clipboard" }, "fill": { @@ -383,7 +383,7 @@ "message": "폴더 편집" }, "editFolderWithName": { - "message": "Edit folder: $FOLDERNAME$", + "message": "폴더 편집: $FOLDERNAME$", "placeholders": { "foldername": { "content": "$1", @@ -471,10 +471,10 @@ "message": "패스프레이즈 생성됨" }, "usernameGenerated": { - "message": "Username generated" + "message": "사용자 이름 생성" }, "emailGenerated": { - "message": "Email generated" + "message": "이메일 생성" }, "regeneratePassword": { "message": "비밀번호 재생성" @@ -548,39 +548,39 @@ "message": "보관함 검색" }, "resetSearch": { - "message": "Reset search" + "message": "검색 초기화" }, "archiveNoun": { - "message": "Archive", + "message": "보관", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "보관", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "보관 해제" }, "itemsInArchive": { - "message": "Items in archive" + "message": "보관함의 항목" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "보관함의 항목" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "보관된 항목은 여기에 표시되며 일반 검색 결과 및 자동 완성 제안에서 제외됩니다." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "항목이 보관함으로 이동되었습니다" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "항목 보관 해제됨" }, "archiveItem": { - "message": "Archive item" + "message": "항목 보관" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" }, "edit": { "message": "편집" @@ -589,7 +589,7 @@ "message": "보기" }, "viewLogin": { - "message": "View login" + "message": "로그인 보기" }, "noItemsInList": { "message": "항목이 없습니다." @@ -694,7 +694,7 @@ "message": "사용하고 있는 웹 브라우저가 쉬운 클립보드 복사를 지원하지 않습니다. 직접 복사하세요." }, "verifyYourIdentity": { - "message": "Verify your identity" + "message": "신원을 인증하세요" }, "weDontRecognizeThisDevice": { "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." @@ -1236,7 +1236,7 @@ "message": "웹사이트에서 변경 사항이 감지되면 로그인 비밀번호를 업데이트하라는 메시지를 표시합니다. 모든 로그인된 계정에 적용됩니다." }, "enableUsePasskeys": { - "message": "패스키를 저장 및 사용할지 묻기" + "message": "패스키 저장 및 사용 확인" }, "usePasskeysDesc": { "message": "보관함에 새 패스키를 저장하거나 로그인할지 물어봅니다. 모든 로그인된 계정에 적용됩니다." @@ -3881,7 +3881,7 @@ "message": "Trust user" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "민감한 정보 안전하게 보내세요", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { @@ -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/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 4811d7585ed..e97a1cafcf9 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/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/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index ce2ffa00c40..e1189450671 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Apstiprināt Key Connector domēnu" }, + "atRiskLoginsSecured": { + "message": "Labs darbs riskam pakļauto pieteikšanās vienumu drošības uzlabošanā!" + }, "settingDisabledByPolicy": { "message": "Šis iestatījums ir atspējots apvienības pamatnostādnēs.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 4641fc0416b..fcf73a37e45 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/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/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 40370d4b980..93f78303a5c 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/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/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/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/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 7091c084082..66d1ce615e1 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/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/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/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/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 7d8760a8710..73b8afa2966 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Key Connector-domein bevestigen" }, + "atRiskLoginsSecured": { + "message": "Goed gedaan, je hebt je risicovolle inloggegevens verbeterd!" + }, "settingDisabledByPolicy": { "message": "Deze instelling is uitgeschakeld door het beleid van uw organisatie.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/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/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/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/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 13d00bc6f88..78fb5e832a6 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Potwierdź domenę Key Connector" }, + "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/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index b1a6bc73f63..e3a82f42ca7 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Confirmar domínio do Conector de Chave" }, + "atRiskLoginsSecured": { + "message": "Ótimo trabalho protegendo suas credenciais em risco!" + }, "settingDisabledByPolicy": { "message": "Essa configuração está desativada pela política da sua organização.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 54eff3eb2ed..db2eb776d7f 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Confirmar o domínio do Key Connector" }, + "atRiskLoginsSecured": { + "message": "Excelente trabalho ao proteger as suas credenciais em risco!" + }, "settingDisabledByPolicy": { "message": "Esta configuração está desativada pela política da sua organização.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 9c1e2bcd79a..4b2913ce55b 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/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/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 1100c4b382c..8661d78552e 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Подтвердите домен соединителя ключей" }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, "settingDisabledByPolicy": { "message": "Этот параметр отключен политикой вашей организации.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 76d4464489b..649556ca64b 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/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/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 0d10ec1dd6b..fe86ad298c9 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Potvrdiť doménu Key Connectora" }, + "atRiskLoginsSecured": { + "message": "Skvelá práca pri zabezpečení vašich ohrozených prihlasovacích údajov!" + }, "settingDisabledByPolicy": { "message": "Politika organizácie vypla toto nastavenie.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 923fd2ce058..cd7eda9a4fa 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/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/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 0cd98548b0f..3421dc1fae1 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Потврдите домен конектора кључа" }, + "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/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index e5de8bb5edf..07ed7a491f1 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Bekräfta Key Connector-domän" }, + "atRiskLoginsSecured": { + "message": "Bra jobbat med att säkra upp dina inloggninar i riskzonen!" + }, "settingDisabledByPolicy": { "message": "Denna inställning är inaktiverad enligt din organisations policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 68ae29a7a93..c4f0fffd143 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Key Connector டொமைனை உறுதிப்படுத்து" }, + "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/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 026e24dbd3a..e3e6953b0df 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/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/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index cfb23d95a02..7487dea84bd 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/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/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 206c0da5b88..e33addd805c 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Key Connector alan adını doğrulayın" }, + "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/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 1adbf19496b..dba38faaec6 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Підтвердити домен Key Connector" }, + "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/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 885fe83f667..055e5155955 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "Xác nhận tên miền Key Connector" }, + "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/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 738e2c13ecb..1d1a6674e18 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "确认 Key Connector 域名" }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, "settingDisabledByPolicy": { "message": "此设置被您组织的策略禁用了。", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b83e78a3b02..e2d9ff2068f 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -6,7 +6,7 @@ "message": "Bitwarden logo" }, "extName": { - "message": "Bitwarden - 密碼管理工具", + "message": "Bitwarden 密碼管理器", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { @@ -5715,6 +5715,9 @@ "confirmKeyConnectorDomain": { "message": "確認 Key Connector 網域" }, + "atRiskLoginsSecured": { + "message": "你已成功保護有風險的登入項目,做得好!" + }, "settingDisabledByPolicy": { "message": "此設定已被你的組織原則停用。", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." From 6fdeefef3df0e453df7469ea39bd4517debb83ac Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 08:54:37 +0200 Subject: [PATCH 35/35] Autosync the updated translations (#17011) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/af/messages.json | 8 ++++- apps/desktop/src/locales/ar/messages.json | 8 ++++- apps/desktop/src/locales/az/messages.json | 8 ++++- apps/desktop/src/locales/be/messages.json | 8 ++++- apps/desktop/src/locales/bg/messages.json | 8 ++++- apps/desktop/src/locales/bn/messages.json | 8 ++++- apps/desktop/src/locales/bs/messages.json | 8 ++++- apps/desktop/src/locales/ca/messages.json | 32 ++++++++++++-------- apps/desktop/src/locales/cs/messages.json | 8 ++++- apps/desktop/src/locales/cy/messages.json | 8 ++++- apps/desktop/src/locales/da/messages.json | 8 ++++- apps/desktop/src/locales/de/messages.json | 8 ++++- apps/desktop/src/locales/el/messages.json | 8 ++++- apps/desktop/src/locales/en_GB/messages.json | 8 ++++- apps/desktop/src/locales/en_IN/messages.json | 8 ++++- apps/desktop/src/locales/eo/messages.json | 8 ++++- apps/desktop/src/locales/es/messages.json | 8 ++++- apps/desktop/src/locales/et/messages.json | 8 ++++- apps/desktop/src/locales/eu/messages.json | 8 ++++- apps/desktop/src/locales/fa/messages.json | 8 ++++- apps/desktop/src/locales/fi/messages.json | 8 ++++- apps/desktop/src/locales/fil/messages.json | 8 ++++- apps/desktop/src/locales/fr/messages.json | 8 ++++- apps/desktop/src/locales/gl/messages.json | 8 ++++- apps/desktop/src/locales/he/messages.json | 8 ++++- apps/desktop/src/locales/hi/messages.json | 8 ++++- apps/desktop/src/locales/hr/messages.json | 16 +++++++--- apps/desktop/src/locales/hu/messages.json | 8 ++++- apps/desktop/src/locales/id/messages.json | 8 ++++- apps/desktop/src/locales/it/messages.json | 8 ++++- apps/desktop/src/locales/ja/messages.json | 8 ++++- apps/desktop/src/locales/ka/messages.json | 8 ++++- apps/desktop/src/locales/km/messages.json | 8 ++++- apps/desktop/src/locales/kn/messages.json | 8 ++++- apps/desktop/src/locales/ko/messages.json | 8 ++++- apps/desktop/src/locales/lt/messages.json | 8 ++++- apps/desktop/src/locales/lv/messages.json | 12 ++++++-- apps/desktop/src/locales/me/messages.json | 8 ++++- apps/desktop/src/locales/ml/messages.json | 8 ++++- apps/desktop/src/locales/mr/messages.json | 8 ++++- apps/desktop/src/locales/my/messages.json | 8 ++++- apps/desktop/src/locales/nb/messages.json | 8 ++++- apps/desktop/src/locales/ne/messages.json | 8 ++++- apps/desktop/src/locales/nl/messages.json | 8 ++++- apps/desktop/src/locales/nn/messages.json | 8 ++++- apps/desktop/src/locales/or/messages.json | 8 ++++- apps/desktop/src/locales/pl/messages.json | 8 ++++- apps/desktop/src/locales/pt_BR/messages.json | 8 ++++- apps/desktop/src/locales/pt_PT/messages.json | 8 ++++- apps/desktop/src/locales/ro/messages.json | 8 ++++- apps/desktop/src/locales/ru/messages.json | 8 ++++- apps/desktop/src/locales/si/messages.json | 8 ++++- apps/desktop/src/locales/sk/messages.json | 8 ++++- apps/desktop/src/locales/sl/messages.json | 8 ++++- apps/desktop/src/locales/sr/messages.json | 10 ++++-- apps/desktop/src/locales/sv/messages.json | 8 ++++- apps/desktop/src/locales/ta/messages.json | 8 ++++- apps/desktop/src/locales/te/messages.json | 8 ++++- apps/desktop/src/locales/th/messages.json | 8 ++++- apps/desktop/src/locales/tr/messages.json | 8 ++++- apps/desktop/src/locales/uk/messages.json | 8 ++++- apps/desktop/src/locales/vi/messages.json | 10 ++++-- apps/desktop/src/locales/zh_CN/messages.json | 8 ++++- apps/desktop/src/locales/zh_TW/messages.json | 8 ++++- 64 files changed, 468 insertions(+), 84 deletions(-) diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 61927009c3a..0701eb833da 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Skrap rekening" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index aa72960577e..7e7f9faf5fe 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "قفل مع كلمة المرور الرئيسية عند إعادة تشغيل" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "حذف الحساب" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 3bcd3f5f92b..4289d577aac 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Yenidən başladılanda ana parol ilə kilidlə" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Tətbiq yenidən başladıqda ana parol və ya PIN tələb edilsin" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Tətbiq yenidən başladıqda ana parol tələb edilsin" + }, "deleteAccount": { "message": "Hesabı sil" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Element arxivdən çıxarıldı" }, "archiveItem": { diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 981f042352d..1f2ac683790 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Выдаліць уліковы запіс" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 64232779cc6..995d992db3e 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Заключване с главната парола при повторно пускане" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Изискване на главната парола или ПИН код при повторно пускане на приложението" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Изискване на главната парола при повторно пускане на приложението" + }, "deleteAccount": { "message": "Изтриване на регистрацията" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Елементът беше преместен в архива" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Елементът беше изваден от архива" }, "archiveItem": { diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index c33565740e2..7a64fec30da 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 0a7061ba291..150b579b09d 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Brisanje računa" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 58c2773f10a..dec07e3efe0 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -1009,16 +1009,16 @@ "message": "Inici de sessió no disponible" }, "noTwoStepProviders": { - "message": "Aquest compte té habilitat l'inici de sessió en dues passes, però aquest navegador web no admet cap dels dos proveïdors configurats." + "message": "Aquest compte té habilitat l'inici de sessió en dos passos, però aquest navegador web no admet cap dels dos proveïdors configurats." }, "noTwoStepProviders2": { "message": "Afegiu proveïdors addicionals que s'adapten millor als dispositius (com ara una aplicació d'autenticació)." }, "twoStepOptions": { - "message": "Opcions d'inici de sessió en dues passes" + "message": "Opcions d'inici de sessió en dos passos" }, "selectTwoStepLoginMethod": { - "message": "Seleccioneu un mètode d'inici de sessió en dues passes" + "message": "Seleccioneu un mètode d'inici de sessió en dos passos" }, "selfHostedEnvironment": { "message": "Entorn d'allotjament propi" @@ -1232,13 +1232,13 @@ } }, "twoStepLoginConfirmation": { - "message": "L'inici de sessió en dues passes fa que el vostre compte siga més segur, ja que obliga a comprovar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dues passes a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" + "message": "L'inici de sessió en dos passos fa que el vostre compte siga més segur, ja que obliga a comprovar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dos passos a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" }, "twoStepLogin": { - "message": "Inici de sessió en dues passes" + "message": "Inici de sessió en dos passos" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "Temps d'espera de la caixa forta" }, "vaultTimeout": { "message": "Temps d'espera de la caixa forta" @@ -1247,7 +1247,7 @@ "message": "Temps d'espera" }, "vaultTimeoutAction1": { - "message": "Timeout action" + "message": "Acció després del temps d'espera" }, "vaultTimeoutDesc": { "message": "Trieu quan es tancarà la vostra caixa forta i feu l'acció seleccionada." @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloqueja amb la contrasenya mestra en reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Suprimeix el compte" }, @@ -1999,7 +2005,7 @@ } }, "learnMoreAboutAuthenticators": { - "message": "Learn more about authenticators" + "message": "Més informació sobre els autenticadors" }, "copyTOTP": { "message": "Copy Authenticator key (TOTP)" @@ -2118,7 +2124,7 @@ "message": "Habilita la integració amb el navegador" }, "enableBrowserIntegrationDesc1": { - "message": "Used to allow biometric unlock in browsers that are not Safari." + "message": "Es fa servir per permetre el desbloqueig biomètric en navegadors que no són Safari." }, "enableDuckDuckGoBrowserIntegration": { "message": "Permet la integració del navegador DuckDuckGo" @@ -3952,15 +3958,15 @@ "message": "With notes, securely store sensitive data like banking or insurance details." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "Accés SSH fàcil per a desenvolupadors" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "Emmagatzemeu les claus i connecteu-vos amb l'agent SSH per a una autenticació ràpida i xifrada.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "Més informació sobre l'agent SSH", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 444d61c172a..50b9cdf8844 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zamknout trezor při restartu pomocí hlavního hesla" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Vyžadovat hlavní heslo nebo PIN při restartu aplikace" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Vyžadovat hlavní heslo při restartu aplikace" + }, "deleteAccount": { "message": "Smazat účet" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Položka byla odebrána z archivu" }, "archiveItem": { diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 5225818ec95..03a29097352 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 33b333a61a1..a5a45db979a 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lås med hovedadgangskode ved genstart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Slet konto" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 77052612eb4..002ef104b96 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Beim Neustart mit Master-Passwort sperren" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Master-Passwort oder PIN beim App-Neustart erfordern" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Master-Passwort beim App-Neustart erfordern" + }, "deleteAccount": { "message": "Konto löschen" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Eintrag wurde ins Archiv verschoben" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, "archiveItem": { diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 858dee3849e..6d381b8fa66 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Κλείδωμα με τον κύριο κωδικό πρόσβασης κατά την επανεκκίνηση" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Διαγραφή λογαριασμού" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 5151cd2502d..6594b2812e3 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index fdc4537c1a6..20745ccfaf1 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 828f82495b0..14972f29f79 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Ŝlosi per la ĉefa pasvorto ĉe relanĉo" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Forigi la konton" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 0368ea0f202..2850044205f 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloquear con contraseña maestra al reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Eliminar cuenta" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index e33fe78e56b..75395b451b6 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lukusta ülemparooliga, kui rakendus taaskäivitatakse" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Kustuta konto" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 9306b55ec8b..0f5ebaca284 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ezabatu kontua" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 4b1d32a2d7a..f097a21b7b7 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "در زمان شروع مجدد، با کلمه عبور اصلی قفل کن" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "حذف حساب کاربری" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 3b54c4d0757..725f1ebb7f2 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lukitse pääsalasanalla uudelleenkäynnistyksen yhteydessä" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Poista tili" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 5334b43c35a..a23e6913c06 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Tanggalin ang account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 38edba7136a..10885ea46f4 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Verrouiller avec le mot de passe principal au redémarrage" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Supprimer le compte" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index be029fc4e2e..3073fef032a 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index cad04a51a9b..fcbd038adf3 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "נעל בעזרת הסיסמה הראשית בהפעלה מחדש" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "מחק חשבון" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 8b00acfe49b..ca2b4cbced1 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 8f21ddd199e..129dd27b09a 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zaključaj glavnom lozinkom kod svakog pokretanja" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Traži glavnu lozinku ili PIN kod ponovnog pokretanja aplikacije" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Traži glavnu lozinku kod ponovnog pokretanja aplikacije" + }, "deleteAccount": { "message": "Obriši račun" }, @@ -2550,7 +2556,7 @@ } }, "vaultCustomTimeoutMinimum": { - "message": "Minimum custom timeout is 1 minute." + "message": "Najmanje prilagođeno vrijeme je 1 minuta." }, "inviteAccepted": { "message": "Pozivnica prihvaćena" @@ -4153,7 +4159,7 @@ "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Poništi arhiviranje" }, "itemsInArchive": { "message": "Stavke u arhivi" @@ -4165,10 +4171,10 @@ "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Stavka poslana u arhivu" }, - "itemUnarchived": { - "message": "Item was unarchived" + "itemWasUnarchived": { + "message": "Stavka vraćena iz arhive" }, "archiveItem": { "message": "Arhiviraj stavku" diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 7dd61d4fd28..8e06affda49 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lezárás mesterjelszóval újraindításkor" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Mesterjelszó vagy PIN kód szükséges az alkalmazás indításakor." + }, + "requireMasterPasswordOnAppRestart": { + "message": "Mesterjelszó szükséges az alkalmazás indításakor." + }, "deleteAccount": { "message": "Fiók törlése" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Az elem az archivumba került." }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Az elem visszavéelre került az archivumból." }, "archiveItem": { diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 73c09e2d972..2aea4e5f1ab 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Hapus akun" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 1ef09903a83..c851dc2b298 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Blocca con password principale al riavvio" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Elimina account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 92e86c44f39..1b61929ac38 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "再起動時にマスターパスワードでロック" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "アカウントを削除" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index f5f21a5eec5..769cc602815 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "ანგარიშის წაშლა" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index be029fc4e2e..3073fef032a 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 7281afada7f..e987d3d811b 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 57af822737b..55da2761122 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "계정 삭제" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index f5f535aee09..38971c8c675 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ištrinti paskyrą" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index e12458b1bc4..487991ddfa4 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Aizslēgt ar galveno paroli pēc pārsāknēšanas" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Pieprasīt galveno paroli vai PIN pēc lietotnes pārsāknēšanas" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Pieprasīt galveno paroli pēc lietotnes pārsāknēšanas" + }, "deleteAccount": { "message": "Izdzēst kontu" }, @@ -4145,11 +4151,11 @@ "message": "Labot saīsni" }, "archiveNoun": { - "message": "Archive", + "message": "Arhīvs", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arhivēt", "description": "Verb" }, "unArchive": { @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Vienums tika ievietots arhīvā" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Vienums tika izņemts no arhīva" }, "archiveItem": { diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 26957e97439..298d13ce2dd 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index e6b7dd42f2a..456ade5aec7 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index be029fc4e2e..3073fef032a 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index c230591d212..7d754800060 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index a4ee9d6fac5..7c70d751245 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Slett konto" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 81e339d099a..bf78d49ff23 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 3330f148c0e..fd568c9bbb4 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bij herstart vergrendelen met hoofdwachtwoord" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Hoofdwachtwoord of pincode vereisen bij herstart van de app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Hoofdwachtwoord vereisen bij herstart van de app" + }, "deleteAccount": { "message": "Account verwijderen" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item uit het archief gehaald" }, "archiveItem": { diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 92caf401a11..bee0f8ed4fc 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index ffe8f673a1e..32c05bd53ff 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 4abade3d1c8..08585674532 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zablokuj hasłem głównym po uruchomieniu ponownym" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Usuń konto" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Element został usunięty z archiwum" }, "archiveItem": { diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 4c09f4f7cfe..4e80920cbf9 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloquear com senha mestra ao reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Exigir senha mestra ou PIN ao reiniciar o app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Exigir senha mestra ao reiniciar o app" + }, "deleteAccount": { "message": "Apagar conta" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "O item foi desarquivado" }, "archiveItem": { diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 716d0089506..0bb2142ba5a 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloquear com a palavra-passe mestra ao reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Exigir a palavra-passe mestra ou PIN ao reiniciar a app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Exigir a palavra-passe mestra ao reiniciar a app" + }, "deleteAccount": { "message": "Eliminar conta" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "O item foi movido para o arquivo" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "O item foi desarquivado" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 9231fe210b2..dab5ed8112e 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ștergere cont" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 5d116cda009..684adf61875 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Блокировать мастер-паролем при перезапуске" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Требовать мастер-пароль или PIN при перезапуске приложения" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Требовать мастер-пароль при перезапуске приложения" + }, "deleteAccount": { "message": "Удалить аккаунт" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Элемент был отправлен в архив" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Элемент был разархивирован" }, "archiveItem": { diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index ece911b848b..397bbbe23c7 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 9c98013eb67..66a42c52182 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Pri reštarte zamknúť hlavným heslom" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Pri reštarte aplikácie vyžadovať hlavné heslo alebo PIN" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Pri reštarte aplikácie vyžadovať hlavné heslo" + }, "deleteAccount": { "message": "Odstrániť účet" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Položka bola odobraná z archívu" }, "archiveItem": { diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index e63d37a92c6..597cb62b4ea 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index bb2fae4e2a3..20e55677171 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Закључајте са главном лозинком при поновном покретању" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Брисање налога" }, @@ -4167,8 +4173,8 @@ "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, - "itemUnarchived": { - "message": "Ставка враћена из архиве" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Архивирај ставку" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 4e9133cd6f8..575e5755441 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lås med huvudlösenord vid omstart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Kräv huvudlösenord eller PIN-kod vid omstart av appen" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Kräv huvudlösenord vid omstart av appen" + }, "deleteAccount": { "message": "Radera konto" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Objektet skickades till arkivet" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Objektet har avarkiverats" }, "archiveItem": { diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index ed4e61b04f8..0ee270c981b 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "மறுதொடக்கம் செய்யும் போது முதன்மை கடவுச்சொல்லுடன் பூட்டவும்" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "கணக்கை நீக்கவும்" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index be029fc4e2e..3073fef032a 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index 60362ce09a6..a2637894dc4 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "ลบบัญชีผู้ใช้" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 54fb09d147e..0d93d84fa2a 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Yeniden başlatmada ana parola ile kilitle" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Uygulama yeniden başlatıldığında ana parola veya PIN iste" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Uygulama yeniden başlatıldığında ana parola iste" + }, "deleteAccount": { "message": "Hesabı sil" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Kayıt arşive gönderildi" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, "archiveItem": { diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 3f80e8c1e9e..577ce4a5d78 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Блокувати головним паролем при перезапуску" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Видалити обліковий запис" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 51274907720..f1341734453 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Khóa bằng mật khẩu chính khi khởi động lại" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Xóa tài khoản" }, @@ -4167,8 +4173,8 @@ "itemWasSentToArchive": { "message": "Mục đã được chuyển vào kho lưu trữ" }, - "itemUnarchived": { - "message": "Mục đã được bỏ lưu trữ" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Lưu trữ mục" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index d3ec23a7994..9a8aa724778 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "重启后使用主密码锁定" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "App 重启时要求主密码或 PIN 码" + }, + "requireMasterPasswordOnAppRestart": { + "message": "App 重启时要求主密码" + }, "deleteAccount": { "message": "删除账户" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "项目已发送到归档" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "项目已取消归档" }, "archiveItem": { diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index f22651b960b..9b29eb12a2d 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "重啟後使用主密碼鎖定" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "要求在重新啟動應用程式時輸入密碼或 PIN 碼" + }, + "requireMasterPasswordOnAppRestart": { + "message": "在應用程式重啟時重新詢問主密碼" + }, "deleteAccount": { "message": "刪除帳戶" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "項目已移至封存" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "項目取消封存" }, "archiveItem": {