diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 6c52016d845..36dbd2143bb 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -26,6 +26,9 @@ "reviewAtRiskPasswords": { "message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, + "reviewAtRiskLoginsPrompt": { + "message": "Review at-risk logins" + }, "dataLastUpdated": { "message": "Data last updated: $DATE$", "placeholders": { @@ -127,6 +130,9 @@ } } }, + "criticalApplicationsMarked": { + "message": "critical applications marked" + }, "countOfCriticalApplications": { "message": "$COUNT$ critical applications", "placeholders": { @@ -235,6 +241,15 @@ "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "criticalApplicationsMarkedSuccess": { + "message": "$COUNT$ applications marked as critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "applicationsMarkedAsCriticalFail": { "message": "Failed to mark applications as critical" }, @@ -259,6 +274,12 @@ "membersWithAccessToAtRiskItemsForCriticalApps": { "message": "Members with access to at-risk items for critical applications" }, + "membersWithAtRiskPasswords": { + "message": "Members with at-risk passwords" + }, + "membersWillReceiveNotification": { + "message": "Members will receive a notification to resolve at-risk logins through the browser extension." + }, "membersAtRiskCount": { "message": "$COUNT$ members at-risk", "placeholders": { @@ -352,8 +373,11 @@ "prioritizeCriticalApplications": { "message": "Prioritize critical applications" }, - "atRiskItems": { - "message": "At-risk items" + "selectCriticalApplicationsDescription": { + "message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks." + }, + "clickIconToMarkAppAsCritical": { + "message": "Click the star icon to mark an app as critical" }, "markAsCriticalPlaceholder": { "message": "Mark as critical functionality will be implemented in a future update" @@ -361,15 +385,6 @@ "applicationReviewSaved": { "message": "Application review saved" }, - "applicationsMarkedAsCritical": { - "message": "$COUNT$ applications marked as critical", - "placeholders": { - "count": { - "content": "$1", - "example": "3" - } - } - }, "newApplicationsReviewed": { "message": "New applications reviewed" }, @@ -841,6 +856,9 @@ "favorites": { "message": "Favorites" }, + "taskSummary": { + "message": "Task summary" + }, "types": { "message": "Types" }, @@ -9787,6 +9805,9 @@ "assignTasks": { "message": "Assign tasks" }, + "assignTasksToMembers": { + "message": "Assign tasks to members for guided resolution" + }, "assignToCollections": { "message": "Assign to collections" }, diff --git a/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 b/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 new file mode 100644 index 00000000000..c982d0e9d3f Binary files /dev/null and b/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 differ diff --git a/apps/web/src/videos/access-intelligence-assign-tasks.mp4 b/apps/web/src/videos/access-intelligence-assign-tasks.mp4 new file mode 100644 index 00000000000..d6f5e01ae22 Binary files /dev/null and b/apps/web/src/videos/access-intelligence-assign-tasks.mp4 differ 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 650726628c6..387d594d4e3 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 @@ -56,6 +56,7 @@ import { OrganizationReportSummary, ReportStatus, ReportState, + ApplicationHealthReportDetail, } from "../../models/report-models"; import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; import { RiskInsightsApiService } from "../api/risk-insights-api.service"; @@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService { enrichedReportData$ = this._enrichedReportDataSubject.asObservable(); // New applications that haven't been reviewed (reviewedDate === null) - newApplications$: Observable = this.rawReportData$.pipe( + newApplications$: Observable = this.rawReportData$.pipe( map((reportState) => { - if (!reportState.data?.applicationData) { - return []; - } - return reportState.data.applicationData - .filter((app) => app.reviewedDate === null) - .map((app) => app.applicationName); + const reportApplications = reportState.data?.applicationData || []; + + const newApplications = + reportState?.data?.reportData.filter((reportApp) => + reportApplications.some( + (app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null, + ), + ) || []; + return newApplications; + }), + distinctUntilChanged((prev, curr) => { + if (prev.length !== curr.length) { + return false; + } + return prev.every( + (app, i) => + app.applicationName === curr[i].applicationName && + app.atRiskPasswordCount === curr[i].atRiskPasswordCount, + ); }), - distinctUntilChanged( - (prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]), - ), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService { } // Create a set for quick lookup of the new critical apps - const newCriticalAppNamesSet = new Set(criticalApplications); + const newCriticalAppNamesSet = criticalApplications.map((ca) => ({ + applicationName: ca, + isCritical: true, + })); const existingApplicationData = report!.applicationData || []; - const updatedApplicationData = this._mergeApplicationData( + const updatedApplicationData = this._updateApplicationData( existingApplicationData, newCriticalAppNamesSet, ); @@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService { } /** - * Saves review status for new applications and optionally marks selected ones as critical. - * This method: - * 1. Sets reviewedDate to current date for all applications where reviewedDate === null - * 2. Sets isCritical = true for applications in the selectedCriticalApps array + * Saves review status for new applications and optionally marks + * selected ones as critical * - * @param selectedCriticalApps Array of application names to mark as critical (can be empty) + * @param reviewedApplications Array of application names to mark as reviewed * @returns Observable of updated ReportState */ - saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable { - this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", { - criticalAppsCount: selectedCriticalApps.length, - }); + saveApplicationReviewStatus$( + reviewedApplications: OrganizationReportApplication[], + ): Observable { + this.logService.info( + `[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`, + ); return this.rawReportData$.pipe( take(1), @@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService { this._userId$.pipe(filter((userId) => !!userId)), ), map(([reportState, organizationDetails, userId]) => { + const report = reportState?.data; + if (!report) { + throwError(() => Error("Tried save reviewed applications without a report")); + } + const existingApplicationData = reportState?.data?.applicationData || []; - const updatedApplicationData = this._updateReviewStatusAndCriticalFlags( + const updatedApplicationData = this._updateApplicationData( existingApplicationData, - selectedCriticalApps, + reviewedApplications, ); + // Updated summary data after changing critical apps + const updatedSummaryData = this.reportService.getApplicationsSummary( + report!.reportData, + updatedApplicationData, + ); + // Used for creating metrics with updated application data + const manualEnrichedApplications = report!.reportData.map( + (application): ApplicationHealthReportDetailEnriched => ({ + ...application, + isMarkedAsCritical: this.reportService.isCriticalApplication( + application, + updatedApplicationData, + ), + }), + ); + // For now, merge the report with the critical marking flag to make the enriched type + // We don't care about the individual ciphers in this instance + // After the report and enriched report types are consolidated, this mapping can be removed + // and the class will expose getCriticalApplications + const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData); + const updatedState = { ...reportState, data: { ...reportState.data, + summaryData: updatedSummaryData, applicationData: updatedApplicationData, }, } as ReportState; @@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService { criticalApps: updatedApplicationData.filter((app) => app.isCritical).length, }); - return { reportState, organizationDetails, updatedState, userId }; + return { reportState, organizationDetails, updatedState, userId, metrics }; }), - switchMap(({ reportState, organizationDetails, updatedState, userId }) => { + switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => { return from( this.riskInsightsEncryptionService.encryptRiskInsightsReport( { @@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService { organizationDetails, updatedState, encryptedData, + metrics, })), ); }), - switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => { + switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => { this.logService.debug( `[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`, ); @@ -521,26 +563,44 @@ export class RiskInsightsOrchestratorService { return of({ ...reportState }); } - return this.reportApiService - .updateRiskInsightsApplicationData$( - reportState.data.id, - organizationDetails.organizationId, - { - data: { - applicationData: encryptedData.encryptedApplicationData.toSdk(), - }, + // Update applications data with critical marking + const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + applicationData: encryptedData.encryptedApplicationData.toSdk(), }, - ) - .pipe( - map(() => updatedState), - catchError((error: unknown) => { - this.logService.error( - "[RiskInsightsOrchestratorService] Failed to save review status", - error, - ); - return of({ ...reportState, error: "Failed to save application review status" }); - }), - ); + }, + ); + + // Update summary after recomputing + const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + summaryData: encryptedData.encryptedSummaryData.toSdk(), + metrics: metrics.toRiskInsightsMetricsData(), + }, + }, + ); + + return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe( + map(() => updatedState), + tap((finalState) => { + this._flagForUpdatesSubject.next({ + ...finalState, + }); + }), + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Failed to save review status", + error, + ); + return of({ ...reportState, error: "Failed to save application review status" }); + }), + ); }), ); } @@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService { // Updates the existing application data to include critical applications // Does not remove critical applications not in the set - private _mergeApplicationData( + private _updateApplicationData( existingApplications: OrganizationReportApplication[], - criticalApplications: Set, + updatedApplications: (Partial & { applicationName: string })[], ): OrganizationReportApplication[] { - const setToMerge = new Set(criticalApplications); + const arrayToMerge = [...updatedApplications]; const updatedApps = existingApplications.map((app) => { - const foundCritical = setToMerge.has(app.applicationName); + // Check if there is an updated app + const foundUpdatedIndex = arrayToMerge.findIndex( + (ua) => ua.applicationName == app.applicationName, + ); - if (foundCritical) { - setToMerge.delete(app.applicationName); + let foundApp: Partial | null = null; + // Remove the updated app from the list + if (foundUpdatedIndex >= 0) { + foundApp = arrayToMerge[foundUpdatedIndex]; + arrayToMerge.splice(foundUpdatedIndex, 1); } - return { - ...app, - isCritical: foundCritical || app.isCritical, + applicationName: app.applicationName, + isCritical: foundApp?.isCritical || app.isCritical, + reviewedDate: foundApp?.reviewedDate || app.reviewedDate, }; }); - setToMerge.forEach((applicationName) => { - updatedApps.push({ - applicationName, - isCritical: true, + const newElements: OrganizationReportApplication[] = arrayToMerge.map( + (newApp): OrganizationReportApplication => ({ + applicationName: newApp.applicationName, + isCritical: newApp.isCritical ?? false, reviewedDate: null, - }); - }); + }), + ); - return updatedApps; - } - - /** - * Updates review status and critical flags for applications. - * Sets reviewedDate for all apps with null reviewedDate. - * Sets isCritical flag for apps in the criticalApplications array. - * - * @param existingApplications Current application data - * @param criticalApplications Array of application names to mark as critical - * @returns Updated application data with review dates and critical flags - */ - private _updateReviewStatusAndCriticalFlags( - existingApplications: OrganizationReportApplication[], - criticalApplications: string[], - ): OrganizationReportApplication[] { - const criticalSet = new Set(criticalApplications); - const currentDate = new Date(); - - return existingApplications.map((app) => { - const shouldMarkCritical = criticalSet.has(app.applicationName); - const needsReviewDate = app.reviewedDate === null; - - // Only create new object if changes are needed - if (needsReviewDate || shouldMarkCritical) { - return { - ...app, - reviewedDate: needsReviewDate ? currentDate : app.reviewedDate, - isCritical: shouldMarkCritical || app.isCritical, - }; - } - - return app; - }); + return updatedApps.concat(newElements); } // Toggles the isCritical flag on applications via criticalApplicationName 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 2dc669f5727..cdfdbe740a0 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 @@ -10,6 +10,8 @@ import { DrawerType, RiskInsightsEnrichedData, ReportStatus, + ApplicationHealthReportDetail, + OrganizationReportApplication, } from "../../models"; import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service"; @@ -38,7 +40,7 @@ export class RiskInsightsDataService { readonly hasCiphers$: Observable = of(null); // New applications that need review (reviewedDate === null) - readonly newApplications$: Observable = of([]); + readonly newApplications$: Observable = of([]); // ------------------------- Drawer Variables --------------------- // Drawer variables unified into a single BehaviorSubject @@ -257,7 +259,7 @@ export class RiskInsightsDataService { return this.orchestrator.removeCriticalApplication$(hostname); } - saveApplicationReviewStatus(selectedCriticalApps: string[]) { + saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) { return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps); } } 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 2cb9140f174..c1d2cdda3e2 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 @@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; +import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component"; import { RiskInsightsComponent } from "./risk-insights.component"; import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; @NgModule({ - imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], + imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent], providers: [ + safeProvider(DefaultAdminTaskService), safeProvider({ provide: MemberCipherDetailsApiService, useClass: MemberCipherDetailsApiService, 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 150c66ad2d4..8a2b2825208 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,10 +1,11 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, lastValueFrom } from "rxjs"; import { AllActivitiesService, + ApplicationHealthReportDetail, ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -13,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga 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"; @@ -20,7 +22,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; -import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; +import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-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 @@ -40,7 +42,7 @@ export class AllActivityComponent implements OnInit { totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; newApplicationsCount = 0; - newApplications: string[] = []; + newApplications: ApplicationHealthReportDetail[] = []; passwordChangeMetricHasProgressBar = false; allAppsHaveReviewDate = false; isAllCaughtUp = false; @@ -127,27 +129,38 @@ export class AllActivityComponent implements OnInit { * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. */ - onReviewNewApplications = async () => { + async onReviewNewApplications() { + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + + if (!organizationId) { + return; + } + + // Pass organizationId via dialog data instead of having the dialog retrieve it from route. + // This ensures organizationId is immediately available when dialog opens, preventing + // timing issues where the dialog's checkForTasksToAssign() method runs before + // organizationId is populated via async route subscription. const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, { newApplications: this.newApplications, + organizationId: organizationId as OrganizationId, }); - await firstValueFrom(dialogRef.closed); - }; + await lastValueFrom(dialogRef.closed); + } /** * Handles the "View at-risk members" link click. * Opens the at-risk members drawer for critical applications only. */ - onViewAtRiskMembers = async () => { + async onViewAtRiskMembers() { await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers"); - }; + } /** * Handles the "View at-risk applications" link click. * Opens the at-risk applications drawer for critical applications only. */ - onViewAtRiskApplications = async () => { + async onViewAtRiskApplications() { await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications"); - }; + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html new file mode 100644 index 00000000000..875e86ed40b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html @@ -0,0 +1,78 @@ +
+ +
+ +
+ + + {{ atRiskCriticalMembersCount() }} + {{ "membersWithAtRiskPasswords" | i18n }} + for + {{ criticalApplicationsCount() }} + {{ "criticalApplications" | i18n }} + + + +
+ +
+ + {{ atRiskCriticalMembersCount() }} + + + {{ "membersWithAtRiskPasswords" | i18n }} + +
+
+ + +
+ +
+
+ + {{ criticalApplicationsCount() }} + + + of {{ totalApplicationsCount() }} total + +
+ + {{ "criticalApplications" | i18n }} at-risk + +
+
+
+ + +
+ + + +
+ {{ "membersWillReceiveNotification" | i18n }} +
+
+
+
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 new file mode 100644 index 00000000000..ac1b241a54b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -0,0 +1,45 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { + ButtonModule, + CalloutComponent, + IconTileComponent, + TypographyModule, +} from "@bitwarden/components"; +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. + * + * Important: This component provides its own instances of AccessIntelligenceSecurityTasksService + * and DefaultAdminTaskService. These services are scoped to this component to ensure proper + * dependency injection when the component is dynamically rendered within the structure. + * Without these providers, Angular would throw NullInjectorError when trying to inject + * DefaultAdminTaskService, which is required by AccessIntelligenceSecurityTasksService. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-assign-tasks-view", + templateUrl: "./assign-tasks-view.component.html", + imports: [ + CommonModule, + ButtonModule, + TypographyModule, + I18nPipe, + IconTileComponent, + DarkImageSourceDirective, + CalloutComponent, + ], + providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], +}) +export class AssignTasksViewComponent { + readonly criticalApplicationsCount = input.required(); + readonly totalApplicationsCount = input.required(); + readonly atRiskCriticalMembersCount = input.required(); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html new file mode 100644 index 00000000000..6ac6ea768b5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html @@ -0,0 +1,97 @@ + + + {{ + currentView() === DialogView.SelectApplications + ? ("prioritizeCriticalApplications" | i18n) + : ("assignTasksToMembers" | i18n) + }} + + +
+ @if (currentView() === DialogView.SelectApplications) { +
+

+ {{ "selectCriticalApplicationsDescription" | i18n }} +

+ +
+ +

+ {{ "clickIconToMarkAppAsCritical" | i18n }} +

+
+ + +
+ } + + @if (currentView() === DialogView.AssignTasks) { + + + } +
+ + @if (currentView() === DialogView.SelectApplications) { + + + + + } + @if (currentView() == DialogView.AssignTasks) { + + + + + + } +
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 new file mode 100644 index 00000000000..ff238e2636a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -0,0 +1,276 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Inject, + inject, + signal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { from, switchMap } 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"; +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, + DIALOG_DATA, + DialogModule, + DialogRef, + DialogService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; + +import { AssignTasksViewComponent } from "./assign-tasks-view.component"; +import { ReviewApplicationsViewComponent } from "./review-applications-view.component"; + +export interface NewApplicationsDialogData { + newApplications: ApplicationHealthReportDetail[]; + /** + * Organization ID is passed via dialog data instead of being retrieved from route params. + * This ensures organizationId is available immediately when the dialog opens, + * preventing async timing issues where user clicks "Mark as critical" before + * the route subscription has fired. + */ + organizationId: OrganizationId; +} + +/** + * View states for dialog navigation + * Using const object pattern per ADR-0025 (Deprecate TypeScript Enums) + */ +export const DialogView = Object.freeze({ + SelectApplications: "select", + AssignTasks: "assign", +} as const); + +export type DialogView = (typeof DialogView)[keyof typeof DialogView]; + +// Possible results for closing the dialog +export const NewApplicationsDialogResultType = Object.freeze({ + Close: "close", + Complete: "complete", +} as const); +export type NewApplicationsDialogResultType = + (typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType]; + +@Component({ + selector: "dirt-new-applications-dialog", + templateUrl: "./new-applications-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ButtonModule, + DialogModule, + TypographyModule, + I18nPipe, + AssignTasksViewComponent, + ReviewApplicationsViewComponent, + ], +}) +export class NewApplicationsDialogComponent { + destroyRef = inject(DestroyRef); + + // View state management + protected readonly currentView = signal(DialogView.SelectApplications); + // Expose DialogView constants to template + protected readonly DialogView = DialogView; + + // Review new applications view + // Applications selected to save as critical applications + protected readonly selectedApplications = signal>(new Set()); + + // Assign tasks variables + readonly criticalApplicationsCount = signal(0); + readonly totalApplicationsCount = signal(0); + readonly atRiskCriticalMembersCount = signal(0); + readonly saving = signal(false); + + // Loading states + protected readonly markingAsCritical = signal(false); + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData, + private dialogRef: DialogRef, + private dataService: RiskInsightsDataService, + private toastService: ToastService, + private i18nService: I18nService, + private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private logService: LogService, + ) {} + + /** + * Opens the new applications dialog + * @param dialogService The dialog service instance + * @param data Dialog data containing the list of new applications and organizationId + * @returns Dialog reference + */ + static open(dialogService: DialogService, data: NewApplicationsDialogData) { + return dialogService.open( + NewApplicationsDialogComponent, + { + data, + }, + ); + } + + getApplications() { + return this.dialogParams.newApplications; + } + + /** + * Toggles the selection state of an application. + * @param applicationName The application to toggle + */ + toggleSelection(applicationName: string) { + this.selectedApplications.update((current) => { + const temp = new Set(current); + if (temp.has(applicationName)) { + temp.delete(applicationName); + } else { + temp.add(applicationName); + } + return temp; + }); + } + + /** + * Toggles the selection state of all applications. + * If all are selected, unselect all. Otherwise, select all. + */ + toggleAll() { + const allApplicationNames = this.dialogParams.newApplications.map((app) => app.applicationName); + const allSelected = this.selectedApplications().size === allApplicationNames.length; + + this.selectedApplications.update(() => { + return allSelected ? new Set() : new Set(allApplicationNames); + }); + } + + handleMarkAsCritical() { + if (this.markingAsCritical() || this.saving()) { + return; // Prevent action if already processing + } + this.markingAsCritical.set(true); + + const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) => + this.selectedApplications().has(newApp.applicationName), + ); + + const atRiskCriticalMembersCount = getUniqueMembers( + onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails), + ).length; + this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount); + + this.currentView.set(DialogView.AssignTasks); + this.markingAsCritical.set(false); + } + + /** + * Handles the assign tasks button click + */ + protected handleAssignTasks() { + if (this.saving()) { + return; // Prevent double-click + } + 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) + .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, + ), + }), + ) || []; + return from( + this.accessIntelligenceSecurityTasksService.assignTasks( + this.dialogParams.organizationId, + manualEnrichedApplications, + ), + ); + }), + ) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("applicationReviewSaved"), + message: this.i18nService.t("newApplicationsReviewed"), + }); + this.saving.set(false); + this.handleAssigningCompleted(); + }, + error: (error: unknown) => { + this.logService.error( + "[NewApplicationsDialog] Failed to save application review or assign tasks", + error, + ); + this.saving.set(false); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorSavingReviewStatus"), + message: this.i18nService.t("pleaseTryAgain"), + }); + }, + }); + } + + /** + * Closes the dialog when the "Cancel" button is selected + */ + handleCancel() { + this.dialogRef.close(NewApplicationsDialogResultType.Close); + } + + /** + * Handles the tasksAssigned event from the embedded component. + * Closes the dialog with success indicator. + */ + protected handleAssigningCompleted = () => { + // Tasks were successfully assigned - close dialog + this.dialogRef.close(NewApplicationsDialogResultType.Complete); + }; + + /** + * Handles the back event from the embedded component. + * Returns to the select applications view. + */ + protected onBack = () => { + this.currentView.set(DialogView.SelectApplications); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html new file mode 100644 index 00000000000..15d8160a55d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html @@ -0,0 +1,83 @@ +
+ + +
+ + + + + + + + + + + + @for (app of filteredApplications(); track app.applicationName) { + + + + + + + + } + +
+ + + {{ "application" | i18n }} + + {{ "atRiskPasswords" | i18n }} + + {{ "totalPasswords" | i18n }} + + {{ "atRiskMembers" | i18n }} +
+ + +
+ + {{ app.applicationName }} +
+
+ {{ app.atRiskPasswordCount }} + + {{ app.passwordCount }} + + {{ app.atRiskMemberCount }} +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts new file mode 100644 index 00000000000..7a269d3aa15 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from "@angular/common"; +import { Component, input, output, ChangeDetectionStrategy, signal, computed } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { ButtonModule, DialogModule, SearchModule, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-review-applications-view", + templateUrl: "./review-applications-view.component.html", + imports: [ + CommonModule, + ButtonModule, + DialogModule, + FormsModule, + SearchModule, + TypographyModule, + I18nPipe, + ], +}) +export class ReviewApplicationsViewComponent { + readonly applications = input.required(); + readonly selectedApplications = input.required>(); + + protected readonly searchText = signal(""); + + // Filter applications based on search text + protected readonly filteredApplications = computed(() => { + const search = this.searchText().toLowerCase(); + if (!search) { + return this.applications(); + } + return this.applications().filter((app) => app.applicationName.toLowerCase().includes(search)); + }); + + // Return the selected applications from the view + onToggleSelection = output(); + onToggleAll = output(); + + toggleSelection(applicationName: string): void { + this.onToggleSelection.emit(applicationName); + } + + toggleAll(): void { + this.onToggleAll.emit(); + } + + isAllSelected(): boolean { + const filtered = this.filteredApplications(); + return ( + filtered.length > 0 && + filtered.every((app) => this.selectedApplications().has(app.applicationName)) + ); + } + + onSearchTextChanged(searchText: string): void { + this.searchText.set(searchText); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html deleted file mode 100644 index f7a5441030e..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - {{ "prioritizeCriticalApplications" | i18n }} -
-
- - - - - - - - - - @for (app of newApplications; track app) { - - - - - - } - -
- {{ "application" | i18n }} - - {{ "atRiskItems" | i18n }} -
- - -
- - {{ app }} -
-
-
-
- - - - -
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts deleted file mode 100644 index c9df3283fae..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, inject } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - ButtonModule, - DialogModule, - DialogRef, - DialogService, - ToastService, - TypographyModule, -} from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; - -export interface NewApplicationsDialogData { - newApplications: 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: "./new-applications-dialog.component.html", - imports: [CommonModule, ButtonModule, DialogModule, TypographyModule, I18nPipe], -}) -export class NewApplicationsDialogComponent { - protected newApplications: string[] = []; - protected selectedApplications: Set = new Set(); - - private dialogRef = inject(DialogRef); - private dataService = inject(RiskInsightsDataService); - private toastService = inject(ToastService); - private i18nService = inject(I18nService); - private logService = inject(LogService); - - /** - * Opens the new applications dialog - * @param dialogService The dialog service instance - * @param data Dialog data containing the list of new applications - * @returns Dialog reference - */ - static open(dialogService: DialogService, data: NewApplicationsDialogData) { - const ref = dialogService.open( - NewApplicationsDialogComponent, - { - data, - }, - ); - - // Set the component's data after opening - const instance = ref.componentInstance as NewApplicationsDialogComponent; - if (instance) { - instance.newApplications = data.newApplications; - } - - return ref; - } - - /** - * Toggles the selection state of an application. - * @param applicationName The application to toggle - */ - toggleSelection = (applicationName: string) => { - if (this.selectedApplications.has(applicationName)) { - this.selectedApplications.delete(applicationName); - } else { - this.selectedApplications.add(applicationName); - } - }; - - /** - * Checks if an application is currently selected. - * @param applicationName The application to check - * @returns True if selected, false otherwise - */ - isSelected = (applicationName: string): boolean => { - return this.selectedApplications.has(applicationName); - }; - - /** - * Handles the "Mark as Critical" button click. - * Saves review status for all new applications and marks selected ones as critical. - * Closes the dialog on success. - */ - onMarkAsCritical = async () => { - const selectedCriticalApps = Array.from(this.selectedApplications); - - try { - await firstValueFrom(this.dataService.saveApplicationReviewStatus(selectedCriticalApps)); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("applicationReviewSaved"), - message: - selectedCriticalApps.length > 0 - ? this.i18nService.t("applicationsMarkedAsCritical", selectedCriticalApps.length) - : this.i18nService.t("newApplicationsReviewed"), - }); - - // Close dialog with success indicator - this.dialogRef.close(true); - } catch { - this.logService.error("[NewApplicationsDialog] Failed to save review status"); - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorSavingReviewStatus"), - message: this.i18nService.t("pleaseTryAgain"), - }); - } - }; -} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index b1cf43b2118..b4e2bf466b9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -91,6 +91,7 @@ export class AllApplicationsComponent implements OnInit { markAppsAsCritical = async () => { this.markingAsCritical = true; + const count = this.selectedUrls.size; this.dataService .saveCriticalApplications(Array.from(this.selectedUrls)) @@ -100,7 +101,7 @@ export class AllApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), }); this.selectedUrls.clear(); this.markingAsCritical = false;