diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9b26ec271c0..939e703b040 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -59,8 +59,42 @@ "createNewLoginItem": { "message": "Create new login item" }, - "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Once you mark applications critical, they will display here" + "percentageCompleted": { + "message": "$PERCENT$% complete", + "placeholders": { + "percent": { + "content": "$1", + "example": "75" + } + } + }, + "securityTasksCompleted": { + "message": "$COUNT$ out of $TOTAL$ security tasks completed", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "5" + } + } + }, + "passwordChangeProgress": { + "message": "Password change progress" + }, + "assignMembersTasksToMonitorProgress": { + "message": "Assign members tasks to monitor progress" + }, + "onceYouReviewApplications": { + "message": "Once you review applications and mark them as critical, they will display here." + }, + "sendReminders": { + "message": "Send reminders" + }, + "criticalApplicationsActivityDescription": { + "message": "Once you mark applications critical, they will display here." }, "viewAtRiskMembers": { "message": "View at-risk members" @@ -108,6 +142,15 @@ } } }, + "countOfAtRiskPasswords": { + "message": "$COUNT$ passwords at-risk", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "notifiedMembersWithCount": { "message": "Notified members ($COUNT$)", "placeholders": { @@ -9523,6 +9566,9 @@ "assign": { "message": "Assign" }, + "assignTasks": { + "message": "Assign tasks" + }, "assignToCollections": { "message": "Assign to collections" }, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts index f1eebf81d73..3ea67d8f7c9 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from "rxjs"; +import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "../models"; import { OrganizationReportSummary } from "../models/report-models"; export class AllActivitiesService { @@ -22,6 +23,18 @@ export class AllActivitiesService { reportSummary$ = this.reportSummarySubject$.asObservable(); + private allApplicationsDetailsSubject$: BehaviorSubject< + LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] + > = new BehaviorSubject([]); + allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable(); + + private atRiskPasswordsCountSubject$ = new BehaviorSubject(0); + atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable(); + + private passwordChangeProgressMetricHasProgressBarSubject$ = new BehaviorSubject(false); + passwordChangeProgressMetricHasProgressBar$ = + this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable(); + setCriticalAppsReportSummary(summary: OrganizationReportSummary) { this.reportSummarySubject$.next({ ...this.reportSummarySubject$.getValue(), @@ -41,4 +54,20 @@ export class AllActivitiesService { totalAtRiskApplicationCount: summary.totalAtRiskApplicationCount, }); } + + setAllAppsReportDetails( + applications: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + ) { + const totalAtRiskPasswords = applications.reduce( + (sum, app) => sum + app.atRiskPasswordCount, + 0, + ); + this.atRiskPasswordsCountSubject$.next(totalAtRiskPasswords); + + this.allApplicationsDetailsSubject$.next(applications); + } + + setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) { + this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index 69d936d3016..53ee3ffa892 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -6,3 +6,4 @@ export * from "./risk-insights-api.service"; export * from "./risk-insights-report.service"; export * from "./risk-insights-data.service"; export * from "./all-activities.service"; +export * from "./security-tasks-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts new file mode 100644 index 00000000000..63fec460162 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts @@ -0,0 +1,53 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { SecurityTasksApiService, TaskMetrics } from "./security-tasks-api.service"; + +describe("SecurityTasksApiService", () => { + const apiServiceMock = mock(); + let service: SecurityTasksApiService; + + beforeEach(() => { + service = new SecurityTasksApiService(apiServiceMock); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("getTaskMetrics", () => { + it("should call apiService.send with correct parameters", (done) => { + const orgId = { toString: () => "org-123" } as OrganizationId; + const mockMetrics: TaskMetrics = { completedTasks: 2, totalTasks: 5 }; + apiServiceMock.send.mockReturnValue(Promise.resolve(mockMetrics)); + + service.getTaskMetrics(orgId).subscribe((metrics) => { + expect(apiServiceMock.send).toHaveBeenCalledWith( + "GET", + "/tasks/org-123/metrics", + null, + true, + true, + ); + expect(metrics).toEqual(mockMetrics); + done(); + }); + }); + + it("should propagate errors from apiService.send", (done) => { + const orgId = { toString: () => "org-456" } as OrganizationId; + const error = new Error("API error"); + apiServiceMock.send.mockReturnValue(Promise.reject(error)); + + service.getTaskMetrics(orgId).subscribe({ + next: () => {}, + error: (err: unknown) => { + expect(err).toBe(error); + done(); + }, + }); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts new file mode 100644 index 00000000000..92bb9207453 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts @@ -0,0 +1,25 @@ +import { from, Observable } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +export type TaskMetrics = { + completedTasks: number; + totalTasks: number; +}; + +export class SecurityTasksApiService { + constructor(private apiService: ApiService) {} + + getTaskMetrics(orgId: OrganizationId): Observable { + const dbResponse = this.apiService.send( + "GET", + `/tasks/${orgId.toString()}/metrics`, + null, + true, + true, + ); + + return from(dbResponse as Promise); + } +} 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 6848220446b..01bf19f30a4 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 @@ -10,6 +10,7 @@ import { RiskInsightsApiService, RiskInsightsDataService, RiskInsightsReportService, + SecurityTasksApiService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -79,6 +80,11 @@ import { RiskInsightsComponent } from "./risk-insights.component"; useClass: AllActivitiesService, deps: [], }), + safeProvider({ + provide: SecurityTasksApiService, + useClass: SecurityTasksApiService, + deps: [ApiService], + }), ], }) export class AccessIntelligenceModule {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts index 7de339358f3..2dc7c6a9c79 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts @@ -11,7 +11,7 @@ import { ButtonModule, LinkModule, TypographyModule } from "@bitwarden/component imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule], host: { class: - "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6", + "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6 tw-h-56 tw-max-h-56", }, }) export class ActivityCardComponent { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html new file mode 100644 index 00000000000..9b194954f0e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html @@ -0,0 +1,75 @@ +
+
+ {{ "passwordChangeProgress" | i18n }} +
+ + @if (renderMode === renderModes.noCriticalApps) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ "onceYouReviewApplications" | i18n }} +
+ } + + @if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndNoTasks) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ "countOfAtRiskPasswords" | i18n: atRiskPasswordsCount }} +
+ +
+ +
+ } + + @if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndTasks) { +
+ {{ "percentageCompleted" | i18n: completedPercent }} +
+ +
+ {{ + "securityTasksCompleted" | i18n: completedTasksCount : totalTasksCount + }} +
+ +
+
+
{{ completedTasksCount }}
+
{{ totalTasksCount }}
+
+
+ + + + + + } +
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts new file mode 100644 index 00000000000..b7a36a79988 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts @@ -0,0 +1,204 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AllActivitiesService, + LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + SecurityTasksApiService, + TaskMetrics, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { + ButtonModule, + ProgressModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; +import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; + +export const RenderMode = { + noCriticalApps: "noCriticalApps", + criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", + criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", +} as const; +export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; + +@Component({ + selector: "dirt-password-change-metric", + imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], + templateUrl: "./password-change-metric.component.html", + providers: [DefaultAdminTaskService], +}) +export class PasswordChangeMetricComponent implements OnInit { + protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); + private completedTasks: number = 0; + private totalTasks: number = 0; + private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] = + []; + + atRiskAppsCount: number = 0; + atRiskPasswordsCount: number = 0; + private organizationId!: OrganizationId; + private destroyRef = new Subject(); + renderMode: RenderMode = "noCriticalApps"; + + async ngOnInit(): Promise { + this.activatedRoute.paramMap + .pipe( + switchMap((paramMap) => { + const orgId = paramMap.get("organizationId"); + if (orgId) { + this.organizationId = orgId as OrganizationId; + return this.securityTasksApiService.getTaskMetrics(this.organizationId); + } + return of({ totalTasks: 0, completedTasks: 0 }); + }), + takeUntil(this.destroyRef), + ) + .subscribe((metrics) => { + this.taskMetrics$.next(metrics); + }); + + combineLatest([ + this.taskMetrics$, + this.allActivitiesService.reportSummary$, + this.allActivitiesService.atRiskPasswordsCount$, + this.allActivitiesService.allApplicationsDetails$, + ]) + .pipe(takeUntil(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; + + // No critical apps setup + this.renderMode = + summary.totalCriticalApplicationCount === 0 ? RenderMode.noCriticalApps : this.renderMode; + + // Critical apps setup with at-risk apps but no tasks + this.renderMode = + summary.totalCriticalApplicationCount > 0 && + summary.totalCriticalAtRiskApplicationCount >= 0 && + taskMetrics.totalTasks === 0 + ? RenderMode.criticalAppsWithAtRiskAppsAndNoTasks + : this.renderMode; + + // Critical apps setup with at-risk apps and tasks + this.renderMode = + summary.totalAtRiskApplicationCount > 0 && + summary.totalCriticalAtRiskApplicationCount >= 0 && + taskMetrics.totalTasks > 0 + ? RenderMode.criticalAppsWithAtRiskAppsAndTasks + : this.renderMode; + + this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( + this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, + ); + }); + } + + constructor( + private activatedRoute: ActivatedRoute, + private securityTasksApiService: SecurityTasksApiService, + private allActivitiesService: AllActivitiesService, + private adminTaskService: DefaultAdminTaskService, + protected toastService: ToastService, + protected i18nService: I18nService, + ) {} + + get completedPercent(): number { + if (this.totalTasks === 0) { + return 0; + } + return Math.round((this.completedTasks / this.totalTasks) * 100); + } + + get completedTasksCount(): number { + switch (this.renderMode) { + case RenderMode.noCriticalApps: + case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: + return 0; + + case RenderMode.criticalAppsWithAtRiskAppsAndTasks: + return this.completedTasks; + + default: + return 0; + } + } + + get totalTasksCount(): number { + switch (this.renderMode) { + case RenderMode.noCriticalApps: + return 0; + + case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: + return this.atRiskAppsCount; + + case RenderMode.criticalAppsWithAtRiskAppsAndTasks: + return this.totalTasks; + + default: + return 0; + } + } + + get canAssignTasks(): boolean { + return this.atRiskAppsCount > this.totalTasks ? true : false; + } + + get renderModes() { + return RenderMode; + } + + async assignTasks() { + const taskCount = await this.requestPasswordChange(); + this.taskMetrics$.next({ + totalTasks: this.totalTasks + taskCount, + completedTasks: this.completedTasks, + }); + } + + // TODO: this method is shared between here and critical-applications.component.ts + async requestPasswordChange() { + const apps = this.allApplicationsDetails; + const cipherIds = apps + .filter((_) => _.atRiskPasswordCount > 0) + .flatMap((app) => app.atRiskCipherIds); + + const distinctCipherIds = Array.from(new Set(cipherIds)); + + const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ + cipherId: cipherId as CipherId, + type: SecurityTaskType.UpdateAtRiskCredential, + })); + + try { + await this.adminTaskService.bulkCreateTasks(this.organizationId as 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; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html index 8d564502ee4..3e60347fcef 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html @@ -17,10 +17,15 @@ } @if (!(isLoading$ | async) && !(noData$ | async)) { -
-
+
    +
  • + +
  • + +
  • +
  • +
  • 0" > -
-
+ + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts index e69dc2b06e5..f1aa6f1041b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts @@ -15,12 +15,18 @@ import { getById } from "@bitwarden/common/platform/misc"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { ActivityCardComponent } from "./activity-card.component"; +import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ selector: "tools-all-activity", - imports: [ApplicationsLoadingComponent, SharedModule, ActivityCardComponent], + imports: [ + ApplicationsLoadingComponent, + SharedModule, + ActivityCardComponent, + PasswordChangeMetricComponent, + ], templateUrl: "./all-activity.component.html", }) export class AllActivityComponent implements OnInit { @@ -30,6 +36,7 @@ export class AllActivityComponent implements OnInit { totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; + passwordChangeMetricHasProgressBar = false; destroyRef = inject(DestroyRef); @@ -51,6 +58,12 @@ export class AllActivityComponent implements OnInit { this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; }); + + this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((hasProgressBar) => { + this.passwordChangeMetricHasProgressBar = hasProgressBar; + }); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index f092b1575f0..7848d37ea94 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -101,6 +101,7 @@ export class CriticalApplicationsComponent implements OnInit { this.applicationSummary = this.reportService.generateApplicationsSummary(applications); this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary); + this.allActivitiesService.setAllAppsReportDetails(applications); } }); }