diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 27faf6f4063..23c430feedd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12161,5 +12161,11 @@ "example": "5" } } + }, + "confirmNoSelectedCriticalApplicationsTitle": { + "message": "No critical applications are selected" + }, + "confirmNoSelectedCriticalApplicationsDesc": { + "message": "Are you sure you want to continue?" } } 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 4b765a5502e..ab59a36aa6a 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 @@ -43,7 +43,7 @@
- {{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }} + {{ "newPasswordsAtRisk" | i18n: unassignedCipherIds() }}
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 509b3e1314a..30e1db7b438 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 @@ -66,16 +66,13 @@ export class PasswordChangeMetricComponent implements OnInit { 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(() => { + readonly unassignedCipherIds = computed(() => { const atRiskIds = this._atRiskCipherIds(); const tasks = this._tasks(); @@ -83,12 +80,20 @@ export class PasswordChangeMetricComponent implements OnInit { return atRiskIds.length; } - const assignedIdSet = new Set(tasks.map((task) => task.cipherId)); + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); return unassignedIds.length; }); + readonly atRiskPasswordCount = computed(() => { + const atRiskIds = this._atRiskCipherIds(); + const atRiskIdsSet = new Set(atRiskIds); + + return atRiskIdsSet.size; + }); + readonly currentView = computed(() => { if (!this._hasCriticalApplications()) { return PasswordChangeView.EMPTY; @@ -96,7 +101,7 @@ export class PasswordChangeMetricComponent implements OnInit { if (this.tasksCount() === 0) { return PasswordChangeView.NO_TASKS_ASSIGNED; } - if (this.atRiskPasswordCount() > 0) { + if (this.unassignedCipherIds() > 0) { return PasswordChangeView.NEW_TASKS_AVAILABLE; } return PasswordChangeView.PROGRESS; 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 index 8e597234f14..09fb5cb7ad9 100644 --- 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 @@ -38,8 +38,8 @@ @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 index e415fbf9ad0..8655baccda3 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 @@ -2,12 +2,15 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, + computed, DestroyRef, Inject, inject, + Injector, + Signal, signal, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { from, switchMap, take } from "rxjs"; import { @@ -17,7 +20,8 @@ import { 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 { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; import { ButtonModule, DIALOG_DATA, @@ -70,9 +74,9 @@ export type NewApplicationsDialogResultType = (typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType]; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-new-applications-dialog", templateUrl: "./new-applications-dialog.component.html", - changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, ButtonModule, @@ -95,10 +99,41 @@ export class NewApplicationsDialogComponent { // Applications selected to save as critical applications protected readonly selectedApplications = signal>(new Set()); - // Assign tasks variables - readonly atRiskCriticalApplicationsCount = signal(0); - readonly totalCriticalApplicationsCount = signal(0); - readonly atRiskCriticalMembersCount = signal(0); + // Used to determine if there are unassigned at-risk cipher IDs + private readonly _tasks!: Signal; + + // Computed properties for selected applications + protected readonly newCriticalApplications = computed(() => { + return this.dialogParams.newApplications.filter((newApp) => + this.selectedApplications().has(newApp.applicationName), + ); + }); + + // New at risk critical applications + protected readonly newAtRiskCriticalApplications = computed(() => { + return this.newCriticalApplications().filter((app) => app.atRiskPasswordCount > 0); + }); + + // Count of unique members with at-risk passwords in newly marked critical applications + protected readonly atRiskCriticalMembersCount = computed(() => { + return getUniqueMembers(this.newCriticalApplications().flatMap((x) => x.atRiskMemberDetails)) + .length; + }); + + protected readonly newUnassignedAtRiskCipherIds = computed(() => { + const newAtRiskCipherIds = this.newCriticalApplications().flatMap((app) => app.atRiskCipherIds); + const tasks = this._tasks(); + + if (tasks.length === 0) { + return newAtRiskCipherIds; + } + + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); + const unassignedIds = newAtRiskCipherIds.filter((id) => !assignedIdSet.has(id)); + return unassignedIds; + }); + readonly saving = signal(false); // Loading states @@ -106,13 +141,21 @@ export class NewApplicationsDialogComponent { constructor( @Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData, - private dialogRef: DialogRef, private dataService: RiskInsightsDataService, - private toastService: ToastService, + private dialogRef: DialogRef, + private dialogService: DialogService, private i18nService: I18nService, - private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private injector: Injector, private logService: LogService, - ) {} + private 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, + }); + } /** * Opens the new applications dialog @@ -170,53 +213,57 @@ export class NewApplicationsDialogComponent { }); } - handleMarkAsCritical() { - if (this.markingAsCritical() || this.saving()) { - return; // Prevent action if already processing + // Checks if there are selected applications and proceeds to assign tasks + async handleMarkAsCritical() { + if (this.selectedApplications().size === 0) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "confirmNoSelectedCriticalApplicationsTitle" }, + content: { key: "confirmNoSelectedCriticalApplicationsDesc" }, + type: "warning", + }); + + if (!confirmed) { + return; + } } - this.markingAsCritical.set(true); - const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) => - this.selectedApplications().has(newApp.applicationName), - ); - - // Count only critical applications that have at-risk passwords - const atRiskCriticalApplicationsCount = onlyNewCriticalApplications.filter( - (app) => app.atRiskPasswordCount > 0, - ).length; - this.atRiskCriticalApplicationsCount.set(atRiskCriticalApplicationsCount); - - // Total number of selected critical applications - this.totalCriticalApplicationsCount.set(onlyNewCriticalApplications.length); - - const atRiskCriticalMembersCount = getUniqueMembers( - onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails), - ).length; - this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount); - - this.currentView.set(DialogView.AssignTasks); - this.markingAsCritical.set(false); + // Skip the assign tasks view if there are no new unassigned at-risk cipher IDs + if (this.newUnassignedAtRiskCipherIds().length === 0) { + this.handleAssignTasks(); + } else { + this.currentView.set(DialogView.AssignTasks); + } } - /** - * Handles the assign tasks button click - */ + // Saves the application review and assigns tasks for unassigned at-risk ciphers protected handleAssignTasks() { if (this.saving()) { return; // Prevent double-click } this.saving.set(true); + const reviewedDate = new Date(); + const updatedApplications = this.dialogParams.newApplications.map((app) => { + const isCritical = this.selectedApplications().has(app.applicationName); + return { + applicationName: app.applicationName, + isCritical, + reviewedDate, + }; + }); + // Save the application review dates and critical markings - this.dataService.criticalApplicationAtRiskCipherIds$ + this.dataService + .saveApplicationReviewStatus(updatedApplications) .pipe( takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule - take(1), // Handle unsubscribe for one off operation - switchMap((criticalApplicationAtRiskCipherIds) => { + take(1), + switchMap(() => { + // Assign password change tasks for unassigned at-risk ciphers for critical applications return from( - this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications( + this.securityTasksService.requestPasswordChangeForCriticalApplications( this.dialogParams.organizationId, - criticalApplicationAtRiskCipherIds, + this.newUnassignedAtRiskCipherIds(), ), ); }),