diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts index 68a1594ff5c..c1aa028da1f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts @@ -1,3 +1,5 @@ +import { CipherId } from "@bitwarden/common/types/guid"; + import { ApplicationHealthReportDetail, MemberDetails, @@ -10,7 +12,6 @@ import { createValidator, isBoolean, isBoundedString, - isBoundedStringArray, isBoundedStringOrNull, isBoundedPositiveNumber, BOUNDED_ARRAY_MAX_LENGTH, @@ -33,6 +34,10 @@ export const isMemberDetails = createValidator({ }); export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails); +export function isCipherId(value: unknown): value is CipherId { + return value == null || isBoundedString(value); +} +export const isCipherIdArray = createBoundedArrayGuard(isCipherId); /** * Type guard to validate ApplicationHealthReportDetail structure * Exported for testability @@ -40,11 +45,11 @@ export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails); */ export const isApplicationHealthReportDetail = createValidator({ applicationName: isBoundedString, - atRiskCipherIds: isBoundedStringArray, + atRiskCipherIds: isCipherIdArray, atRiskMemberCount: isBoundedPositiveNumber, atRiskMemberDetails: isMemberDetailsArray, atRiskPasswordCount: isBoundedPositiveNumber, - cipherIds: isBoundedStringArray, + cipherIds: isCipherIdArray, memberCount: isBoundedPositiveNumber, memberDetails: isMemberDetailsArray, passwordCount: isBoundedPositiveNumber, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts index 33dd8676223..027ef8fb25d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts @@ -1,5 +1,6 @@ import { mock } from "jest-mock-extended"; +import { CipherId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -13,11 +14,14 @@ import { PasswordHealthData, } from "../report-models"; +const mockCipherId1 = "cipher-1" as CipherId; +const mockCipherId2 = "cipher-2" as CipherId; + const mockApplication1: ApplicationHealthReportDetail = { applicationName: "application1.com", passwordCount: 2, atRiskPasswordCount: 1, - atRiskCipherIds: ["cipher-1"], + atRiskCipherIds: [mockCipherId1], memberCount: 2, atRiskMemberCount: 1, memberDetails: [ @@ -33,10 +37,10 @@ const mockApplication1: ApplicationHealthReportDetail = { userGuid: "user-id-2", userName: "tom", email: "tom2@application1.com", - cipherId: "cipher-2", + cipherId: mockCipherId2, }, ], - cipherIds: ["cipher-1", "cipher-2"], + cipherIds: [mockCipherId1, mockCipherId2], }; const mockApplication2: ApplicationHealthReportDetail = { 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 ef666021fdc..a907dcf6d7b 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,7 +1,7 @@ import { Opaque } from "type-fest"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { OrganizationReportId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; @@ -79,12 +79,12 @@ export type ApplicationHealthReportDetail = { applicationName: string; passwordCount: number; atRiskPasswordCount: number; - atRiskCipherIds: string[]; + atRiskCipherIds: CipherId[]; memberCount: number; atRiskMemberCount: number; memberDetails: MemberDetails[]; atRiskMemberDetails: MemberDetails[]; - cipherIds: string[]; + cipherIds: CipherId[]; }; // -------------------- Password Health Report Models -------------------- diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts index 92bb9207453..e81c91a350c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts @@ -1,7 +1,14 @@ import { from, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + SecurityTask, + SecurityTaskData, + SecurityTaskResponse, + SecurityTaskStatus, +} from "@bitwarden/common/vault/tasks"; export type TaskMetrics = { completedTasks: number; @@ -22,4 +29,29 @@ export class SecurityTasksApiService { return from(dbResponse as Promise); } + + // Could not import from @bitwarden/bit-web + // Copying from /bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts + async getAllTasks( + organizationId: OrganizationId, + status?: SecurityTaskStatus | undefined, + ): Promise { + const queryParams = new URLSearchParams(); + + queryParams.append("organizationId", organizationId); + if (status !== undefined) { + queryParams.append("status", status.toString()); + } + + const r = await this.apiService.send( + "GET", + `/tasks/organization?${queryParams.toString()}`, + null, + true, + true, + ); + const response = new ListResponse(r, SecurityTaskResponse); + + return response.data.map((d) => new SecurityTask(new SecurityTaskData(d))); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts index 65ee2c8bb74..7bc0862887b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts @@ -28,7 +28,7 @@ describe("PasswordHealthService", () => { auditService.passwordLeaked.mockImplementation((password: string) => Promise.resolve(password === "leaked" ? 2 : 0), ); - service = new PasswordHealthService(passwordStrengthService, auditService); + service = new PasswordHealthService(auditService, passwordStrengthService); // Setup mock data mockValidCipher = mock({ diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts index 267c1dc9563..2d94bf828b8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts @@ -14,8 +14,8 @@ import { export class PasswordHealthService { constructor( - private passwordStrengthService: PasswordStrengthServiceAbstraction, private auditService: AuditService, + private passwordStrengthService: PasswordStrengthServiceAbstraction, ) {} /** 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 index a077c8345b5..59affad10da 100644 --- 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 @@ -32,7 +32,7 @@ import { } 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 { CipherId, 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"; @@ -89,6 +89,10 @@ export class RiskInsightsOrchestratorService { private _hasCiphersSubject$ = new BehaviorSubject(null); hasCiphers$ = this._hasCiphersSubject$.asObservable(); + private _criticalApplicationAtRiskCipherIdsSubject$ = new BehaviorSubject([]); + readonly criticalApplicationAtRiskCipherIds$ = + this._criticalApplicationAtRiskCipherIdsSubject$.asObservable(); + // ------------------------- Report Variables ---------------- private _rawReportDataSubject = new BehaviorSubject({ status: ReportStatus.Initializing, @@ -1150,10 +1154,42 @@ export class RiskInsightsOrchestratorService { this._reportStateSubscription = mergedReportState$ .pipe(takeUntil(this._destroy$)) .subscribe((state) => { + // Update the raw report data subject this._rawReportDataSubject.next(state.reportState); + + // Update the critical application at risk cipher ids for exposure + const reportState = state.reportState?.data; + if (reportState) { + const criticalApplicationAtRiskCipherIds = this._getCriticalApplicationCipherIds( + reportState.reportData || [], + reportState.applicationData || [], + ); + this._criticalApplicationAtRiskCipherIdsSubject$.next(criticalApplicationAtRiskCipherIds); + } }); } + // Gets the unique cipher IDs that are marked at risk in critical applications + private _getCriticalApplicationCipherIds( + applications: ApplicationHealthReportDetail[], + applicationData: OrganizationReportApplication[], + ): CipherId[] { + const foundCipherIds = applications + .map((app) => { + const isCriticalApplication = this.reportService.isCriticalApplication( + app, + applicationData, + ); + return isCriticalApplication ? app.atRiskCipherIds : []; + }) + .flat(); + + // Use a set to ensure uniqueness + const uniqueCipherIds = new Set([...foundCipherIds]); + + return [...uniqueCipherIds]; + } + // Setup the user ID observable to track the current user private _setupUserId() { // Watch userId changes 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 index d49d7a4a40f..94c9c85f955 100644 --- 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 @@ -1,7 +1,12 @@ import { catchError, EMPTY, from, map, Observable, of, 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 { + CipherId, + 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"; @@ -63,7 +68,7 @@ export class RiskInsightsReportService { ): Map { const cipherMap = new Map(); applications.forEach((app) => { - const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id)); + const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id as CipherId)); cipherMap.set(app.applicationName, filteredCiphers); }); return cipherMap; @@ -346,7 +351,7 @@ export class RiskInsightsReportService { ): ApplicationHealthReportDetail { return { applicationName: application, - cipherIds: [cipherReport.cipher.id], + cipherIds: [cipherReport.cipher.id as CipherId], passwordCount: 1, memberDetails: [...cipherReport.cipherMembers], memberCount: cipherReport.cipherMembers.length, @@ -367,7 +372,7 @@ export class RiskInsightsReportService { memberDetails: getUniqueMembers( existingReport.memberDetails.concat(newCipherReport.cipherMembers), ), - cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), + cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id as CipherId), }; } @@ -377,7 +382,7 @@ export class RiskInsightsReportService { ); return { atRiskPasswordCount: report.atRiskPasswordCount + 1, - atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), + atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id as CipherId), atRiskMemberDetails, atRiskMemberCount: atRiskMemberDetails.length, }; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index 22d8e24562d..2111049ce52 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -11,7 +11,6 @@ export class AllActivitiesService { /// and critical applications. /// Going forward, this class can be simplified by using the RiskInsightsDataService /// as it contains the application summary data. - private reportSummarySubject$ = new BehaviorSubject({ totalMemberCount: 0, totalCriticalMemberCount: 0, @@ -31,12 +30,8 @@ export class AllActivitiesService { private atRiskPasswordsCountSubject$ = new BehaviorSubject(0); atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable(); - private passwordChangeProgressMetricHasProgressBarSubject$ = new BehaviorSubject(false); - passwordChangeProgressMetricHasProgressBar$ = - this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable(); - - private taskCreatedCountSubject$ = new BehaviorSubject(0); - taskCreatedCount$ = this.taskCreatedCountSubject$.asObservable(); + private extendPasswordChangeWidgetSubject$ = new BehaviorSubject(false); + extendPasswordChangeWidget$ = this.extendPasswordChangeWidgetSubject$.asObservable(); constructor(private dataService: RiskInsightsDataService) { // All application summary changes @@ -91,11 +86,7 @@ export class AllActivitiesService { this.allApplicationsDetailsSubject$.next(applications); } - setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) { - this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar); - } - - setTaskCreatedCount(count: number) { - this.taskCreatedCountSubject$.next(count); + setExtendPasswordWidget(hasProgressBar: boolean) { + this.extendPasswordChangeWidgetSubject$.next(hasProgressBar); } } 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 index ad49392df57..7b9255ca821 100644 --- 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 @@ -1,7 +1,7 @@ import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs"; import { distinctUntilChanged, map } from "rxjs/operators"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers"; import { @@ -39,6 +39,7 @@ export class RiskInsightsDataService { readonly isGeneratingReport$: Observable = of(false); readonly criticalReportResults$: Observable = of(null); readonly hasCiphers$: Observable = of(null); + readonly criticalApplicationAtRiskCipherIds$: Observable = of([]); readonly reportProgress$: Observable = of(null); // New applications that need review (reviewedDate === null) @@ -64,6 +65,8 @@ export class RiskInsightsDataService { this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$; this.newApplications$ = this.orchestrator.newApplications$; + this.criticalApplicationAtRiskCipherIds$ = + this.orchestrator.criticalApplicationAtRiskCipherIds$; this.reportProgress$ = this.orchestrator.reportProgress$; this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged()); 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 c1d2cdda3e2..5592e4cc546 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 @@ -20,10 +20,8 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; 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"; @@ -37,22 +35,37 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. @NgModule({ imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent], providers: [ - safeProvider(DefaultAdminTaskService), + safeProvider({ + provide: CriticalAppsApiService, + useClass: CriticalAppsApiService, + deps: [ApiService], + }), safeProvider({ provide: MemberCipherDetailsApiService, useClass: MemberCipherDetailsApiService, deps: [ApiService], }), - safeProvider({ - provide: PasswordHealthService, - useClass: PasswordHealthService, - deps: [PasswordStrengthServiceAbstraction, AuditService], - }), safeProvider({ provide: RiskInsightsApiService, useClass: RiskInsightsApiService, deps: [ApiService], }), + safeProvider({ + provide: SecurityTasksApiService, + useClass: SecurityTasksApiService, + deps: [ApiService], + }), + safeProvider(DefaultAdminTaskService), + safeProvider({ + provide: AccessIntelligenceSecurityTasksService, + useClass: AccessIntelligenceSecurityTasksService, + deps: [DefaultAdminTaskService, SecurityTasksApiService], + }), + safeProvider({ + provide: PasswordHealthService, + useClass: PasswordHealthService, + deps: [AuditService, PasswordStrengthServiceAbstraction], + }), safeProvider({ provide: RiskInsightsReportService, useClass: RiskInsightsReportService, @@ -86,26 +99,11 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. useClass: CriticalAppsService, deps: [KeyService, EncryptService, CriticalAppsApiService], }), - safeProvider({ - provide: CriticalAppsApiService, - useClass: CriticalAppsApiService, - deps: [ApiService], - }), safeProvider({ provide: AllActivitiesService, useClass: AllActivitiesService, deps: [RiskInsightsDataService], }), - safeProvider({ - provide: SecurityTasksApiService, - useClass: SecurityTasksApiService, - deps: [ApiService], - }), - safeProvider({ - provide: AccessIntelligenceSecurityTasksService, - useClass: AccessIntelligenceSecurityTasksService, - deps: [AllActivitiesService, DefaultAdminTaskService, ToastService, I18nService], - }), ], }) export class AccessIntelligenceModule {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/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 index 674bc0b5c62..4b765a5502e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/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 @@ -5,75 +5,80 @@ {{ "passwordChangeProgress" | i18n }} - @if (renderMode === renderModes.noCriticalApps) { -
- {{ "assignMembersTasksToMonitorProgress" | i18n }} -
- -
- {{ "onceYouReviewApps" | i18n }} -
- } - - @if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndNoTasks) { -
- {{ "assignMembersTasksToMonitorProgress" | i18n }} -
- -
- {{ - hasExistingTasks - ? ("newPasswordsAtRisk" | i18n: newAtRiskPasswordsCount) - : ("countOfAtRiskPasswords" | i18n: atRiskPasswordsCount) - }} -
- -
- -
- } - - @if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndTasks) { -
- {{ "percentageCompleted" | i18n: completedPercent }} -
- -
- {{ - "securityTasksCompleted" | i18n: completedTasksCount : totalTasksCount - }} -
- -
-
-
{{ completedTasksCount }}
-
{{ totalTasksCount }}
+ @switch (currentView()) { + @case (PasswordChangeViewEnum.EMPTY) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }}
-
- - - - +
+ {{ "onceYouReviewApps" | i18n }} +
+ } + + @case (PasswordChangeViewEnum.NO_TASKS_ASSIGNED) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ + "countOfAtRiskPasswords" | i18n: atRiskPasswordCount() + }} +
+ + @if (atRiskPasswordCount() > 0) { +
+ +
+ } + } + + @case (PasswordChangeViewEnum.NEW_TASKS_AVAILABLE) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }} +
+ +
+ +
+ } + + @case (PasswordChangeViewEnum.PROGRESS) { +
+ {{ "percentageCompleted" | i18n: completedTasksPercent() }} +
+ +
+ {{ + "securityTasksCompleted" | i18n: completedTasksCount() : tasksCount() + }} +
+ +
+
+
{{ completedTasksCount() }}
+
{{ tasksCount() }}
+
+
+ + + } }
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 5c03534720e..509b3e1314a 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,197 +1,169 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, DestroyRef, + Injector, OnInit, + Signal, + computed, + effect, inject, + input, + signal, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute } from "@angular/router"; -import { switchMap, of, BehaviorSubject, combineLatest } from "rxjs"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AllActivitiesService, - ApplicationHealthReportDetailEnriched, - SecurityTasksApiService, - TaskMetrics, - OrganizationReportSummary, + RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; +import { + ButtonModule, + ProgressModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; -import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; -import { RenderMode } from "../../models/activity.models"; import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; +export const PasswordChangeView = { + EMPTY: "empty", + NO_TASKS_ASSIGNED: "noTasksAssigned", + NEW_TASKS_AVAILABLE: "newTasks", + PROGRESS: "progress", +} as const; + +export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof PasswordChangeView]; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class PasswordChangeMetricComponent implements OnInit { + PasswordChangeViewEnum = PasswordChangeView; + private destroyRef = inject(DestroyRef); - protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); - private completedTasks: number = 0; - private totalTasks: number = 0; - private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = []; + // Inputs + // Prefer component input since route param controls UI state + readonly organizationId = input.required(); - atRiskAppsCount: number = 0; - atRiskPasswordsCount: number = 0; - private organizationId!: OrganizationId; - renderMode: RenderMode = "noCriticalApps"; + // Signal states + private readonly _tasks: Signal = signal([]); + private readonly _atRiskCipherIds: Signal = signal([]); + private readonly _hasCriticalApplications: Signal = signal(false); - // 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; + // Computed properties + readonly tasksCount = computed(() => this._tasks().length); + readonly completedTasksCount = computed( + () => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length, + ); + readonly uncompletedTasksCount = computed( + () => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length, + ); + readonly completedTasksPercent = computed(() => { + const total = this.tasksCount(); + // Account for case where there are no tasks to avoid NaN + return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; + }); + + readonly atRiskPasswordCount = computed(() => { + const atRiskIds = this._atRiskCipherIds(); + const tasks = this._tasks(); + + if (tasks.length === 0) { + return atRiskIds.length; + } + + const assignedIdSet = new Set(tasks.map((task) => task.cipherId)); + const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); + + return unassignedIds.length; + }); + + readonly currentView = computed(() => { + if (!this._hasCriticalApplications()) { + return PasswordChangeView.EMPTY; + } + if (this.tasksCount() === 0) { + return PasswordChangeView.NO_TASKS_ASSIGNED; + } + if (this.atRiskPasswordCount() > 0) { + return PasswordChangeView.NEW_TASKS_AVAILABLE; + } + return PasswordChangeView.PROGRESS; + }); constructor( - private activatedRoute: ActivatedRoute, - private securityTasksApiService: SecurityTasksApiService, private allActivitiesService: AllActivitiesService, - protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, - private cdr: ChangeDetectorRef, - ) {} + private i18nService: I18nService, + private injector: Injector, + private riskInsightsDataService: RiskInsightsDataService, + protected securityTasksService: AccessIntelligenceSecurityTasksService, + private toastService: ToastService, + ) { + // Setup the _tasks signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { + initialValue: [], + injector: this.injector, + }); + // Setup the _atRiskCipherIds signal by manually passing in the injector + this._atRiskCipherIds = toSignal( + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + { + initialValue: [], + injector: this.injector, + }, + ); + + this._hasCriticalApplications = toSignal( + this.riskInsightsDataService.criticalReportResults$.pipe( + takeUntilDestroyed(this.destroyRef), + map((report) => { + return report != null && (report.reportData?.length ?? 0) > 0; + }), + ), + { + initialValue: false, + injector: this.injector, + }, + ); + + effect(() => { + const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS; + this.allActivitiesService.setExtendPasswordWidget(isShowingProgress); + }); + } async ngOnInit(): Promise { - combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) - .pipe( - switchMap(([params, _]) => { - const orgId = params.get("organizationId"); - if (orgId) { - this.organizationId = orgId as OrganizationId; - return this.securityTasksApiService.getTaskMetrics(this.organizationId); - } - return of({ totalTasks: 0, completedTasks: 0 }); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((metrics) => { - this.taskMetrics$.next(metrics); - this.cdr.markForCheck(); - }); - - combineLatest([ - this.taskMetrics$, - this.allActivitiesService.reportSummary$, - this.allActivitiesService.atRiskPasswordsCount$, - this.allActivitiesService.allApplicationsDetails$, - ]) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(([taskMetrics, summary, atRiskPasswordsCount, allApplicationsDetails]) => { - this.atRiskAppsCount = summary.totalCriticalAtRiskApplicationCount; - this.atRiskPasswordsCount = atRiskPasswordsCount; - this.completedTasks = taskMetrics.completedTasks; - this.totalTasks = taskMetrics.totalTasks; - this.allApplicationsDetails = allApplicationsDetails; - - // Determine render mode based on state - this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount); - - this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( - this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, - ); - - // Update all computed properties when data changes - this.updateComputedProperties(); - - this.cdr.markForCheck(); - }); - } - - private determineRenderMode( - summary: OrganizationReportSummary, - taskMetrics: TaskMetrics, - atRiskPasswordsCount: number, - ): RenderMode { - // State 1: No critical apps setup - if (summary.totalCriticalApplicationCount === 0) { - return RenderMode.noCriticalApps; - } - - // State 2: Critical apps with at-risk passwords but no tasks assigned yet - // OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks) - if ( - summary.totalCriticalApplicationCount > 0 && - (taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks) - ) { - return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks; - } - - // State 3: Critical apps with at-risk apps and tasks (progress tracking) - if ( - summary.totalCriticalApplicationCount > 0 && - taskMetrics.totalTasks > 0 && - atRiskPasswordsCount <= taskMetrics.totalTasks - ) { - return RenderMode.criticalAppsWithAtRiskAppsAndTasks; - } - - // Default to no critical apps - return RenderMode.noCriticalApps; - } - - /** - * 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); - - // Calculate completed tasks count based on render mode - switch (this.renderMode) { - case RenderMode.noCriticalApps: - case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - this.completedTasksCount = 0; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - this.completedTasksCount = this.completedTasks; - break; - default: - this.completedTasksCount = 0; - } - - // Calculate total tasks count based on render mode - switch (this.renderMode) { - case RenderMode.noCriticalApps: - this.totalTasksCount = 0; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - this.totalTasksCount = this.atRiskAppsCount; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - this.totalTasksCount = this.totalTasks; - break; - default: - this.totalTasksCount = 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() { - return RenderMode; + await this.securityTasksService.loadTasks(this.organizationId()); } async assignTasks() { - await this.accessIntelligenceSecurityTasksService.assignTasks( - this.organizationId, - this.allApplicationsDetails.filter((app) => app.isMarkedAsCritical), - ); + try { + await this.securityTasksService.requestPasswordChangeForCriticalApplications( + this.organizationId(), + this._atRiskCipherIds(), + ); + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + } } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 8cdb927ab65..d0751556517 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -4,8 +4,10 @@
    -
  • - +
  • +
  • diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 7e737be0bdc..06073d93c85 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -1,7 +1,7 @@ -import { Component, DestroyRef, inject, OnInit } from "@angular/core"; +import { Component, DestroyRef, inject, input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, lastValueFrom } from "rxjs"; +import { lastValueFrom } from "rxjs"; import { AllActivitiesService, @@ -10,10 +10,6 @@ import { RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.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 { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -37,13 +33,15 @@ import { NewApplicationsDialogComponent } from "./application-review-dialog/new- templateUrl: "./all-activity.component.html", }) export class AllActivityComponent implements OnInit { - organization: Organization | null = null; + // Prefer component input since route param controls UI state + readonly organizationId = input.required(); + totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; newApplicationsCount = 0; newApplications: ApplicationHealthReportDetail[] = []; - passwordChangeMetricHasProgressBar = false; + extendPasswordChangeWidget = false; allAppsHaveReviewDate = false; isAllCaughtUp = false; hasLoadedApplicationData = false; @@ -53,7 +51,6 @@ export class AllActivityComponent implements OnInit { protected ReportStatusEnum = ReportStatus; constructor( - private accountService: AccountService, protected activatedRoute: ActivatedRoute, protected allActivitiesService: AllActivitiesService, protected dataService: RiskInsightsDataService, @@ -62,53 +59,43 @@ export class AllActivityComponent implements OnInit { ) {} async ngOnInit(): Promise { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.allActivitiesService.reportSummary$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((summary) => { + this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; + this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; + this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; + }); - if (organizationId) { - this.organization = - (await firstValueFrom( - this.organizationService.organizations$(userId).pipe(getById(organizationId)), - )) ?? null; + this.dataService.newApplications$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((newApps) => { + this.newApplications = newApps; + this.newApplicationsCount = newApps.length; + this.updateIsAllCaughtUp(); + }); - this.allActivitiesService.reportSummary$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((summary) => { - this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; - this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; - this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; - }); + this.allActivitiesService.extendPasswordChangeWidget$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((hasProgressBar) => { + this.extendPasswordChangeWidget = hasProgressBar; + }); - this.dataService.newApplications$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((newApps) => { - this.newApplications = newApps; - this.newApplicationsCount = newApps.length; - this.updateIsAllCaughtUp(); - }); - - this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((hasProgressBar) => { - this.passwordChangeMetricHasProgressBar = hasProgressBar; - }); - - this.dataService.enrichedReportData$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((enrichedData) => { - if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) { - this.hasLoadedApplicationData = true; - // Check if all apps have a review date (not null and not undefined) - this.allAppsHaveReviewDate = enrichedData.applicationData.every( - (app) => app.reviewedDate !== null && app.reviewedDate !== undefined, - ); - } else { - this.hasLoadedApplicationData = enrichedData !== null; - this.allAppsHaveReviewDate = false; - } - this.updateIsAllCaughtUp(); - }); - } + this.dataService.enrichedReportData$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((enrichedData) => { + if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) { + this.hasLoadedApplicationData = true; + // Check if all apps have a review date (not null and not undefined) + this.allAppsHaveReviewDate = enrichedData.applicationData.every( + (app) => app.reviewedDate !== null && app.reviewedDate !== undefined, + ); + } else { + this.hasLoadedApplicationData = enrichedData !== null; + this.allAppsHaveReviewDate = false; + } + this.updateIsAllCaughtUp(); + }); } /** diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts index ac1b241a54b..15d927a7714 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -10,9 +10,6 @@ import { import { I18nPipe } from "@bitwarden/ui-common"; import { DarkImageSourceDirective } from "@bitwarden/vault"; -import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; -import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; - /** * Embedded component for displaying task assignment UI. * Not a dialog - intended to be embedded within a parent dialog. @@ -36,7 +33,6 @@ import { AccessIntelligenceSecurityTasksService } from "../../shared/security-ta DarkImageSourceDirective, CalloutComponent, ], - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class AssignTasksViewComponent { readonly criticalApplicationsCount = input.required(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index 8a1a90245b3..e415fbf9ad0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -8,12 +8,10 @@ import { signal, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { from, switchMap } from "rxjs"; +import { from, switchMap, take } from "rxjs"; import { ApplicationHealthReportDetail, - ApplicationHealthReportDetailEnriched, - OrganizationReportApplication, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; @@ -209,40 +207,16 @@ export class NewApplicationsDialogComponent { } this.saving.set(true); - // Create updated organization report application types with new review date - // and critical marking based on selected applications - const newReviewDate = new Date(); - const updatedApplications: OrganizationReportApplication[] = - this.dialogParams.newApplications.map((app) => ({ - applicationName: app.applicationName, - isCritical: this.selectedApplications().has(app.applicationName), - reviewedDate: newReviewDate, - })); - // Save the application review dates and critical markings - this.dataService - .saveApplicationReviewStatus(updatedApplications) + this.dataService.criticalApplicationAtRiskCipherIds$ .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap((updatedState) => { - // After initial save is complete, created the assigned tasks - // for at risk passwords - const updatedStateApplicationData = updatedState?.data?.applicationData || []; - // Manual enrich for type matching - // TODO Consolidate in model updates - const manualEnrichedApplications = - updatedState?.data?.reportData.map( - (application): ApplicationHealthReportDetailEnriched => ({ - ...application, - isMarkedAsCritical: updatedStateApplicationData.some( - (a) => a.applicationName == application.applicationName && a.isCritical, - ), - }), - ) || []; + takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule + take(1), // Handle unsubscribe for one off operation + switchMap((criticalApplicationAtRiskCipherIds) => { return from( - this.accessIntelligenceSecurityTasksService.assignTasks( + this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications( this.dialogParams.organizationId, - manualEnrichedApplications, + criticalApplicationAtRiskCipherIds, ), ); }), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 794df90da53..7b7ca8c42da 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -4,7 +4,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { debounceTime, EMPTY, map, switchMap } from "rxjs"; +import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { @@ -23,7 +23,6 @@ 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 { RiskInsightsTabType } from "../models/risk-insights.models"; import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; @@ -42,7 +41,6 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks SharedModule, AppTableRowScrollableComponent, ], - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class CriticalApplicationsComponent implements OnInit { private destroyRef = inject(DestroyRef); @@ -58,13 +56,13 @@ export class CriticalApplicationsComponent implements OnInit { constructor( protected activatedRoute: ActivatedRoute, - protected router: Router, - protected toastService: ToastService, protected dataService: RiskInsightsDataService, protected criticalAppsService: CriticalAppsService, - protected reportService: RiskInsightsReportService, protected i18nService: I18nService, - private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + protected reportService: RiskInsightsReportService, + protected router: Router, + private securityTasksService: AccessIntelligenceSecurityTasksService, + protected toastService: ToastService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) @@ -131,10 +129,35 @@ export class CriticalApplicationsComponent implements OnInit { }; async requestPasswordChange() { - await this.accessIntelligenceSecurityTasksService.assignTasks( - this.organizationId, - this.dataSource.data, - ); + this.dataService.criticalApplicationAtRiskCipherIds$ + .pipe( + takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule + take(1), // Handle unsubscribe for one off operation + switchMap((cipherIds) => { + return from( + this.securityTasksService.requestPasswordChangeForCriticalApplications( + this.organizationId, + cipherIds, + ), + ); + }), + ) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, + }); } showAppAtRiskMembers = async (applicationName: string) => { 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 deleted file mode 100644 index 6f108a46029..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index fad2afb6e38..4b7d51af174 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 @@ -87,7 +87,7 @@ @if (isRiskInsightsActivityTabFeatureEnabled) { - + } 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 3bc968dabc1..5a5efa8225d 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 @@ -78,7 +78,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { appsCount: number = 0; - private organizationId: OrganizationId = "" as OrganizationId; + protected organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; 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 22f8ea55f51..f6fb41cdbb0 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 @@ -1,14 +1,9 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { - AllActivitiesService, - 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"; +import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; -import { ToastService } from "@bitwarden/components"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -16,18 +11,14 @@ import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; - const defaultAdminTaskServiceSpy = mock(); - const allActivitiesServiceSpy = mock(); - const toastServiceSpy = mock(); - const i18nServiceSpy = mock(); + const defaultAdminTaskServiceMock = mock(); + const securityTasksApiServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( - allActivitiesServiceSpy, - defaultAdminTaskServiceSpy, - toastServiceSpy, - i18nServiceSpy, + defaultAdminTaskServiceMock, + securityTasksApiServiceMock, ); }); @@ -36,104 +27,48 @@ describe("AccessIntelligenceSecurityTasksService", () => { }); describe("assignTasks", () => { - it("should call requestPasswordChange and setTaskCreatedCount", async () => { + it("should call requestPasswordChangeForCriticalApplications and setTaskCreatedCount", async () => { + // Set up test data const organizationId = "org-1" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid1"], - } as ApplicationHealthReportDetailEnriched, - ]; - const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); - await service.assignTasks(organizationId, apps); - expect(spy).toHaveBeenCalledWith(organizationId, apps); - expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2); + const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId]; + const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications"); + + // Call the method + await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds); + + // Verify that the method was called with correct parameters + expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds); }); }); - describe("requestPasswordChange", () => { + describe("requestPasswordChangeForCriticalApplications", () => { it("should create tasks for distinct cipher ids and show success toast", async () => { + // Set up test data const organizationId = "org-2" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 2, - atRiskCipherIds: ["cid1", "cid2"], - } as ApplicationHealthReportDetailEnriched, - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid2"], - } as ApplicationHealthReportDetailEnriched, - ]; - defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); - i18nServiceSpy.t.mockImplementation((key) => key); + const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId]; + defaultAdminTaskServiceMock.bulkCreateTasks.mockResolvedValue(undefined); + const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications"); - const result = await service.requestPasswordChange(organizationId, apps); + // Call the method + await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds); - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ + // Verify that bulkCreateTasks was called with distinct cipher ids + expect(defaultAdminTaskServiceMock.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ { cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential }, { cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential }, ]); - expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ - message: "notifiedMembers", - variant: "success", - title: "success", - }); - expect(result).toBe(2); + // Verify that the method was called with correct parameters + expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds); }); - it("should show error toast and return 0 if bulkCreateTasks throws", async () => { + it("should handle error if defaultAdminTaskService errors", async () => { const organizationId = "org-3" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid3"], - } as ApplicationHealthReportDetailEnriched, - ]; - defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); - i18nServiceSpy.t.mockImplementation((key) => key); + const mockCipherIds = ["cid3" as CipherId]; + defaultAdminTaskServiceMock.bulkCreateTasks.mockRejectedValue(new Error("API fail error")); - const result = await service.requestPasswordChange(organizationId, apps); - - expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ - message: "unexpectedError", - variant: "error", - title: "error", - }); - expect(result).toBe(0); - }); - - it("should not create any tasks if no apps have atRiskPasswordCount > 0", async () => { - const organizationId = "org-4" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 0, - atRiskCipherIds: ["cid4"], - } as ApplicationHealthReportDetailEnriched, - ]; - const result = await service.requestPasswordChange(organizationId, apps); - - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); - expect(result).toBe(0); - }); - - it("should not create any tasks for non-critical apps", async () => { - const organizationId = "org-5" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: false, - atRiskPasswordCount: 2, - atRiskCipherIds: ["cid5", "cid6"], - } as ApplicationHealthReportDetailEnriched, - ]; - const result = await service.requestPasswordChange(organizationId, apps); - - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); - expect(result).toBe(0); + await expect( + service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds), + ).rejects.toThrow("API fail error"); }); }); }); 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 4d7a41007eb..688ab039ca9 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 @@ -1,64 +1,63 @@ -import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; -import { - AllActivitiesService, - ApplicationHealthReportDetailEnriched, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; -import { ToastService } from "@bitwarden/components"; +import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; -@Injectable() +/** + * Service for managing security tasks related to Access Intelligence features + */ export class AccessIntelligenceSecurityTasksService { + private _tasksSubject$ = new BehaviorSubject([]); + tasks$ = this._tasksSubject$.asObservable(); + constructor( - private allActivitiesService: AllActivitiesService, private adminTaskService: DefaultAdminTaskService, - private toastService: ToastService, - private i18nService: I18nService, + private securityTasksApiService: SecurityTasksApiService, ) {} - async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) { - const taskCount = await this.requestPasswordChange(organizationId, apps); - this.allActivitiesService.setTaskCreatedCount(taskCount); + + /** + * Gets security task metrics for the given organization + * + * @param organizationId The organization ID + * @returns Metrics about security tasks such as a count of completed and total tasks + */ + getTaskMetrics(organizationId: OrganizationId) { + return this.securityTasksApiService.getTaskMetrics(organizationId); } - // TODO: this method is shared between here and critical-applications.component.ts - async requestPasswordChange( + /** + * Loads security tasks for the given organization and updates the internal tasks subject + * + * @param organizationId The organization ID + */ + async loadTasks(organizationId: OrganizationId): Promise { + // Loads the tasks to update the service + const tasks = await this.securityTasksApiService.getAllTasks(organizationId); + this._tasksSubject$.next(tasks); + } + + /** + * Bulk assigns password change tasks for critical applications with at-risk passwords + * + * @param organizationId The organization ID + * @param criticalApplicationIds IDs of critical applications with at-risk passwords + */ + async requestPasswordChangeForCriticalApplications( organizationId: OrganizationId, - apps: ApplicationHealthReportDetailEnriched[], - ): Promise { - // Only create tasks for CRITICAL applications with at-risk passwords - const cipherIds = apps - .filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0) - .flatMap((app) => app.atRiskCipherIds); - - const distinctCipherIds = Array.from(new Set(cipherIds)); - + criticalApplicationIds: CipherId[], + ) { + const distinctCipherIds = Array.from(new Set(criticalApplicationIds)); const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ - cipherId: cipherId as CipherId, + cipherId, type: SecurityTaskType.UpdateAtRiskCredential, })); - try { - await this.adminTaskService.bulkCreateTasks(organizationId, tasks); - this.toastService.showToast({ - message: this.i18nService.t("notifiedMembers"), - variant: "success", - title: this.i18nService.t("success"), - }); - - return tasks.length; - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), - }); - } - - return 0; + await this.adminTaskService.bulkCreateTasks(organizationId, tasks); + // Reload tasks after creation + await this.loadTasks(organizationId); } }