diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1f4e1c98e32..763ce6634fe 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -268,6 +268,42 @@ } } }, + "numCriticalApplicationsMarkedSuccess": { + "message": "$COUNT$ applications marked critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "numApplicationsUnmarkedCriticalSuccess": { + "message": "$COUNT$ applications marked not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsCritical": { + "message": "Mark $COUNT$ as critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsNotCritical": { + "message": "Mark $COUNT$ as not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "applicationsMarkedAsCriticalFail": { "message": "Failed to mark applications as critical" }, @@ -10148,6 +10184,9 @@ "assignTasks": { "message": "Assign tasks" }, + "allTasksAssigned": { + "message": "All tasks have been assigned" + }, "assignSecurityTasksToMembers": { "message": "Send notifications to change passwords" }, 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 88af8081a8b..886ae4c5008 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 @@ -228,7 +228,7 @@ export class RiskInsightsOrchestratorService { * @param criticalApplication Application name of the critical application to remove * @returns */ - removeCriticalApplication$(criticalApplication: string): Observable { + removeCriticalApplications$(applicationsToUnmark: Set): Observable { this.logService.info( "[RiskInsightsOrchestratorService] Removing critical applications from report", ); @@ -245,11 +245,10 @@ export class RiskInsightsOrchestratorService { throwError(() => Error("Tried to update critical applications without a report")); } - // Create a set for quick lookup of the new critical apps const existingApplicationData = report!.applicationData || []; - const updatedApplicationData = this._removeCriticalApplication( + const updatedApplicationData = this._removeCriticalApplications( existingApplicationData, - criticalApplication, + applicationsToUnmark, ); // Updated summary data after changing critical apps @@ -917,12 +916,12 @@ export class RiskInsightsOrchestratorService { } // Toggles the isCritical flag on applications via criticalApplicationName - private _removeCriticalApplication( + private _removeCriticalApplications( applicationData: OrganizationReportApplication[], - criticalApplication: string, + applicationsToUnmark: Set, ): OrganizationReportApplication[] { const updatedApplicationData = applicationData.map((application) => { - if (application.applicationName == criticalApplication) { + if (applicationsToUnmark.has(application.applicationName)) { return { ...application, isCritical: false } as OrganizationReportApplication; } return application; 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 d426a6b09c1..8cf799250f2 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 @@ -263,8 +263,8 @@ export class RiskInsightsDataService { return this.orchestrator.saveCriticalApplications$(selectedUrls); } - removeCriticalApplication(hostname: string) { - return this.orchestrator.removeCriticalApplication$(hostname); + removeCriticalApplications(selectedUrls: Set) { + return this.orchestrator.removeCriticalApplications$(selectedUrls); } saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) { 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 5592e4cc546..555d6aa62e0 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 @@ -59,7 +59,7 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. safeProvider({ provide: AccessIntelligenceSecurityTasksService, useClass: AccessIntelligenceSecurityTasksService, - deps: [DefaultAdminTaskService, SecurityTasksApiService], + deps: [DefaultAdminTaskService, SecurityTasksApiService, RiskInsightsDataService], }), safeProvider({ provide: PasswordHealthService, 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 df47adb4635..0487ae726e3 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 @@ -20,7 +20,7 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; import { ButtonModule, @@ -57,10 +57,14 @@ export class PasswordChangeMetricComponent implements OnInit { // Signal states private readonly _tasks: Signal = signal([]); - private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); - private readonly _reportGeneratedAt: Signal = signal( - undefined, + private readonly _unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + private readonly _atRiskCipherIds = toSignal( + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + { initialValue: [] }, ); // Computed properties @@ -74,41 +78,11 @@ export class PasswordChangeMetricComponent implements OnInit { return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; }); - readonly unassignedCipherIds = computed(() => { - const atRiskIds = this._atRiskCipherIds(); - const tasks = this._tasks(); + readonly unassignedCipherIds = computed(() => this._unassignedCipherIds().length); - if (tasks.length === 0) { - return atRiskIds.length; - } - - const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); - - const reportGeneratedAt = this._reportGeneratedAt(); - const completedTasksAfterReportGeneration = reportGeneratedAt - ? tasks.filter( - (task) => - task.status === SecurityTaskStatus.Completed && - new Date(task.revisionDate) >= reportGeneratedAt, - ) - : []; - const completedTaskIds = new Set( - completedTasksAfterReportGeneration.map((task) => task.cipherId), - ); - - // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task - const unassignedIds = atRiskIds.filter( - (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), - ); - - return unassignedIds.length; - }); - - readonly atRiskPasswordCount = computed(() => { + readonly atRiskPasswordCount = computed(() => { const atRiskIds = this._atRiskCipherIds(); const atRiskIdsSet = new Set(atRiskIds); - return atRiskIdsSet.size; }); @@ -119,7 +93,7 @@ export class PasswordChangeMetricComponent implements OnInit { if (this.tasksCount() === 0) { return PasswordChangeView.NO_TASKS_ASSIGNED; } - if (this.unassignedCipherIds() > 0) { + if (this._unassignedCipherIds().length > 0) { return PasswordChangeView.NEW_TASKS_AVAILABLE; } return PasswordChangeView.PROGRESS; @@ -133,10 +107,6 @@ export class PasswordChangeMetricComponent implements OnInit { private toastService: ToastService, ) { this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); - this._atRiskCipherIds = toSignal( - this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { initialValue: [] }, - ); this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( map((report) => { @@ -145,10 +115,6 @@ export class PasswordChangeMetricComponent implements OnInit { ), { initialValue: false }, ); - this._reportGeneratedAt = toSignal( - this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), - { initialValue: undefined }, - ); effect(() => { const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS; @@ -164,7 +130,7 @@ export class PasswordChangeMetricComponent implements OnInit { try { await this.securityTasksService.requestPasswordChangeForCriticalApplications( this.organizationId(), - this._atRiskCipherIds(), + this._unassignedCipherIds(), ); this.toastService.showToast({ message: this.i18nService.t("notifiedMembers"), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index efe07d50683..27864fa2f87 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -21,27 +21,58 @@ > - +
+ @if (selectedUrls().size > 0) { + @if (allSelectedAppsAreCritical()) { + + } @else { + + } + } - + + + +
{ let mockLogService: MockProxy; let mockToastService: MockProxy; let mockDataService: MockProxy; + let mockSecurityTasksService: MockProxy; const reportStatus$ = new BehaviorSubject(ReportStatus.Complete); const enrichedReportData$ = new BehaviorSubject(null); @@ -47,6 +49,7 @@ describe("ApplicationsComponent", () => { appAtRiskMembers: null, atRiskAppDetails: null, }); + const unassignedCriticalCipherIds$ = new BehaviorSubject([]); beforeEach(async () => { mockI18nService = mock(); @@ -54,6 +57,7 @@ describe("ApplicationsComponent", () => { mockLogService = mock(); mockToastService = mock(); mockDataService = mock(); + mockSecurityTasksService = mock(); mockI18nService.t.mockImplementation((key: string) => key); @@ -65,6 +69,9 @@ describe("ApplicationsComponent", () => { get: () => criticalReportResults$, }); Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ }); + Object.defineProperty(mockSecurityTasksService, "unassignedCriticalCipherIds$", { + get: () => unassignedCriticalCipherIds$, + }); await TestBed.configureTestingModule({ imports: [ApplicationsComponent, ReactiveFormsModule], @@ -78,6 +85,7 @@ describe("ApplicationsComponent", () => { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: (): string | null => null } } }, }, + { provide: AccessIntelligenceSecurityTasksService, useValue: mockSecurityTasksService }, ], }).compileComponents(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts index b5fae36bb2e..0020106ba7d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -7,10 +7,10 @@ import { signal, computed, } from "@angular/core"; -import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toObservable, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, startWith } from "rxjs"; +import { combineLatest, debounceTime, EMPTY, map, startWith, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -22,6 +22,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, IconButtonModule, @@ -30,8 +31,10 @@ import { SearchModule, TableDataSource, ToastService, + TooltipDirective, TypographyModule, ChipSelectComponent, + IconComponent, } from "@bitwarden/components"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; @@ -42,6 +45,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; import { ReportLoadingComponent } from "../shared/report-loading.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; export const ApplicationFilterOption = { All: "all", @@ -70,6 +74,8 @@ export type ApplicationFilterOption = ButtonModule, ReactiveFormsModule, ChipSelectComponent, + IconComponent, + TooltipDirective, ], }) export class ApplicationsComponent implements OnInit { @@ -86,13 +92,14 @@ export class ApplicationsComponent implements OnInit { // Template driven properties protected readonly selectedUrls = signal(new Set()); - protected readonly markingAsCritical = signal(false); + protected readonly updatingCriticalApps = signal(false); protected readonly applicationSummary = signal(createNewSummaryData()); protected readonly criticalApplicationsCount = signal(0); protected readonly totalApplicationsCount = signal(0); protected readonly nonCriticalApplicationsCount = computed(() => { return this.totalApplicationsCount() - this.criticalApplicationsCount(); }); + protected readonly organizationId = signal(undefined); // filter related properties protected readonly selectedFilter = signal(ApplicationFilterOption.All); @@ -112,14 +119,46 @@ export class ApplicationsComponent implements OnInit { ]); protected readonly emptyTableExplanation = signal(""); + readonly allSelectedAppsAreCritical = computed(() => { + if (!this.dataSource.filteredData || this.selectedUrls().size == 0) { + return false; + } + + return this.dataSource.filteredData + .filter((row) => this.selectedUrls().has(row.applicationName)) + .every((row) => row.isMarkedAsCritical); + }); + + protected readonly unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + + readonly enableRequestPasswordChange = computed(() => this.unassignedCipherIds().length > 0); + constructor( protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, protected dataService: RiskInsightsDataService, + protected securityTasksService: AccessIntelligenceSecurityTasksService, ) {} async ngOnInit() { + this.activatedRoute.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap(async (orgId) => { + if (orgId) { + this.organizationId.set(orgId as OrganizationId); + } else { + return EMPTY; + } + }), + ) + .subscribe(); + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { if (report != null) { @@ -193,12 +232,8 @@ export class ApplicationsComponent implements OnInit { this.selectedFilter.set(value); } - isMarkedAsCriticalItem(applicationName: string) { - return this.selectedUrls().has(applicationName); - } - markAppsAsCritical = async () => { - this.markingAsCritical.set(true); + this.updatingCriticalApps.set(true); const count = this.selectedUrls().size; this.dataService @@ -209,10 +244,10 @@ export class ApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + message: this.i18nService.t("numCriticalApplicationsMarkedSuccess", count), }); this.selectedUrls.set(new Set()); - this.markingAsCritical.set(false); + this.updatingCriticalApps.set(false); }, error: () => { this.toastService.showToast({ @@ -224,6 +259,65 @@ export class ApplicationsComponent implements OnInit { }); }; + unmarkAppsAsCritical = async () => { + this.updatingCriticalApps.set(true); + const appsToUnmark = this.selectedUrls(); + + this.dataService + .removeCriticalApplications(appsToUnmark) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t( + "numApplicationsUnmarkedCriticalSuccess", + appsToUnmark.size, + ), + variant: "success", + }); + this.selectedUrls.set(new Set()); + this.updatingCriticalApps.set(false); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, + }); + }; + + async requestPasswordChange() { + const orgId = this.organizationId(); + if (!orgId) { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + return; + } + + try { + await this.securityTasksService.requestPasswordChangeForCriticalApplications( + orgId, + this.unassignedCipherIds(), + ); + 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"), + }); + } + } + showAppAtRiskMembers = async (applicationName: string) => { await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; 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 3033bf139c3..7180d50fe05 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 @@ -131,7 +131,7 @@ export class CriticalApplicationsComponent implements OnInit { removeCriticalApplication = async (hostname: string) => { this.dataService - .removeCriticalApplication(hostname) + .removeCriticalApplications(new Set([hostname])) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { 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 f6fb41cdbb0..4ee784337de 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,7 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { + RiskInsightsDataService, + SecurityTasksApiService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -13,12 +16,14 @@ describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; const defaultAdminTaskServiceMock = mock(); const securityTasksApiServiceMock = mock(); + const riskInsightsDataServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( defaultAdminTaskServiceMock, securityTasksApiServiceMock, + riskInsightsDataServiceMock, ); }); 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 688ab039ca9..65a31896341 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,8 +1,10 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -14,10 +16,57 @@ export class AccessIntelligenceSecurityTasksService { private _tasksSubject$ = new BehaviorSubject([]); tasks$ = this._tasksSubject$.asObservable(); + /** + * Observable stream of unassigned critical cipher IDs. + * Returns cipher IDs from critical applications that don't have an associated task + * (either pending or completed after the report was generated). + */ + readonly unassignedCriticalCipherIds$: Observable; + constructor( private adminTaskService: DefaultAdminTaskService, private securityTasksApiService: SecurityTasksApiService, - ) {} + private riskInsightsDataService: RiskInsightsDataService, + ) { + this.unassignedCriticalCipherIds$ = combineLatest([ + this.tasks$, + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + this.riskInsightsDataService.enrichedReportData$, + ]).pipe( + map(([tasks, atRiskCipherIds, reportData]) => { + // If no tasks exist, return all at-risk cipher IDs + if (tasks.length === 0) { + return atRiskCipherIds; + } + + // Get in-progress tasks (awaiting password reset) + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + // Get completed tasks after report generation + const reportGeneratedAt = reportData?.creationDate; + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // Filter out cipher IDs that have a corresponding in-progress or completed task + return atRiskCipherIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); + }), + shareReplay({ + bufferSize: 1, + refCount: true, + }), + ); + } /** * Gets security task metrics for the given organization