1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 11:43:46 +00:00

[PM-26018] All Activity tab - Password change metric (#16644)

This commit is contained in:
Vijay Oommen
2025-10-03 08:58:07 -05:00
committed by GitHub
parent f7a3ad8805
commit 0443c87867
12 changed files with 469 additions and 10 deletions

View File

@@ -59,8 +59,42 @@
"createNewLoginItem": { "createNewLoginItem": {
"message": "Create new login item" "message": "Create new login item"
}, },
"onceYouMarkApplicationsCriticalTheyWillDisplayHere": { "percentageCompleted": {
"message": "Once you mark applications critical, they will display here" "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": { "viewAtRiskMembers": {
"message": "View at-risk members" "message": "View at-risk members"
@@ -108,6 +142,15 @@
} }
} }
}, },
"countOfAtRiskPasswords": {
"message": "$COUNT$ passwords at-risk",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"notifiedMembersWithCount": { "notifiedMembersWithCount": {
"message": "Notified members ($COUNT$)", "message": "Notified members ($COUNT$)",
"placeholders": { "placeholders": {
@@ -9523,6 +9566,9 @@
"assign": { "assign": {
"message": "Assign" "message": "Assign"
}, },
"assignTasks": {
"message": "Assign tasks"
},
"assignToCollections": { "assignToCollections": {
"message": "Assign to collections" "message": "Assign to collections"
}, },

View File

@@ -1,5 +1,6 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "../models";
import { OrganizationReportSummary } from "../models/report-models"; import { OrganizationReportSummary } from "../models/report-models";
export class AllActivitiesService { export class AllActivitiesService {
@@ -22,6 +23,18 @@ export class AllActivitiesService {
reportSummary$ = this.reportSummarySubject$.asObservable(); reportSummary$ = this.reportSummarySubject$.asObservable();
private allApplicationsDetailsSubject$: BehaviorSubject<
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[]
> = new BehaviorSubject<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[]>([]);
allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable();
private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0);
atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable();
private passwordChangeProgressMetricHasProgressBarSubject$ = new BehaviorSubject<boolean>(false);
passwordChangeProgressMetricHasProgressBar$ =
this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable();
setCriticalAppsReportSummary(summary: OrganizationReportSummary) { setCriticalAppsReportSummary(summary: OrganizationReportSummary) {
this.reportSummarySubject$.next({ this.reportSummarySubject$.next({
...this.reportSummarySubject$.getValue(), ...this.reportSummarySubject$.getValue(),
@@ -41,4 +54,20 @@ export class AllActivitiesService {
totalAtRiskApplicationCount: summary.totalAtRiskApplicationCount, 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);
}
} }

View File

@@ -6,3 +6,4 @@ export * from "./risk-insights-api.service";
export * from "./risk-insights-report.service"; export * from "./risk-insights-report.service";
export * from "./risk-insights-data.service"; export * from "./risk-insights-data.service";
export * from "./all-activities.service"; export * from "./all-activities.service";
export * from "./security-tasks-api.service";

View File

@@ -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<ApiService>();
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();
},
});
});
});
});

View File

@@ -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<TaskMetrics> {
const dbResponse = this.apiService.send(
"GET",
`/tasks/${orgId.toString()}/metrics`,
null,
true,
true,
);
return from(dbResponse as Promise<TaskMetrics>);
}
}

View File

@@ -10,6 +10,7 @@ import {
RiskInsightsApiService, RiskInsightsApiService,
RiskInsightsDataService, RiskInsightsDataService,
RiskInsightsReportService, RiskInsightsReportService,
SecurityTasksApiService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; } 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 { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -79,6 +80,11 @@ import { RiskInsightsComponent } from "./risk-insights.component";
useClass: AllActivitiesService, useClass: AllActivitiesService,
deps: [], deps: [],
}), }),
safeProvider({
provide: SecurityTasksApiService,
useClass: SecurityTasksApiService,
deps: [ApiService],
}),
], ],
}) })
export class AccessIntelligenceModule {} export class AccessIntelligenceModule {}

View File

@@ -11,7 +11,7 @@ import { ButtonModule, LinkModule, TypographyModule } from "@bitwarden/component
imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule], imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule],
host: { host: {
class: 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 { export class ActivityCardComponent {

View File

@@ -0,0 +1,75 @@
<div
class="tw-flex tw-flex-col tw-p-6 tw-box-border tw-bg-background tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-rounded-lg tw-h-56 tw-max-h-56"
>
<div bitTypography="h6" class="tw-mb-2">
{{ "passwordChangeProgress" | i18n }}
</div>
@if (renderMode === renderModes.noCriticalApps) {
<div class="tw-items-start tw-mb-2">
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
</div>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{ "onceYouReviewApplications" | i18n }}</span>
</div>
}
@if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndNoTasks) {
<div class="tw-items-start tw-mb-2">
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
</div>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{ "countOfAtRiskPasswords" | i18n: atRiskPasswordsCount }}</span>
</div>
<div class="tw-mt-4">
<button
bitButton
buttonType="secondary"
type="button"
[disabled]="!canAssignTasks"
(click)="assignTasks()"
>
<i class="bwi bwi-envelope tw-mr-2"></i>
{{ "assignTasks" | i18n }}
</button>
</div>
}
@if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndTasks) {
<div class="tw-items-start tw-mb-2">
<span bitTypography="h3">{{ "percentageCompleted" | i18n: completedPercent }}</span>
</div>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{
"securityTasksCompleted" | i18n: completedTasksCount : totalTasksCount
}}</span>
</div>
<div class="tw-mt-4">
<div class="tw-flex tw-justify-between">
<div bitTypography="body2">{{ completedTasksCount }}</div>
<div bitTypography="body2">{{ totalTasksCount }}</div>
</div>
</div>
<bit-progress
[showText]="false"
size="small"
bgColor="primary"
[barWidth]="completedPercent"
[ariaLabel]="'passwordChangeProgressBar' | i18n"
>
</bit-progress>
<!-- TODO: Implement reminder functionality -->
<!-- <div class="tw-items-start tw-mt-4 tw-gap-4">
<button bitButton type="button" buttonType="secondary">
<i class="bwi bwi-envelope" aria-hidden="true"></i>
{{ "sendReminders" | i18n }}
</button>
</div> -->
}
</div>

View File

@@ -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<TaskMetrics>({ 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<void>();
renderMode: RenderMode = "noCriticalApps";
async ngOnInit(): Promise<void> {
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;
}
}

View File

@@ -17,10 +17,15 @@
} }
@if (!(isLoading$ | async) && !(noData$ | async)) { @if (!(isLoading$ | async) && !(noData$ | async)) {
<div class="tw-mt-4 tw-flex tw-flex-col"> <ul
<div class="tw-flex tw-gap-4 tw-col-span-6"> class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
>
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
<dirt-password-change-metric></dirt-password-change-metric>
</li>
<li class="tw-col-span-1">
<dirt-activity-card <dirt-activity-card
class="tw-col-span-2 tw-cursor-pointer"
[title]="'atRiskMembers' | i18n" [title]="'atRiskMembers' | i18n"
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount" [cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
[metricDescription]="'membersAtRiskActivityDescription' | i18n" [metricDescription]="'membersAtRiskActivityDescription' | i18n"
@@ -29,10 +34,11 @@
[showNavigationLink]="totalCriticalAppsAtRiskMemberCount > 0" [showNavigationLink]="totalCriticalAppsAtRiskMemberCount > 0"
> >
</dirt-activity-card> </dirt-activity-card>
</li>
<li class="tw-col-span-1">
<dirt-activity-card <dirt-activity-card
#allAppsOrgAtRiskApplications #allAppsOrgAtRiskApplications
class="tw-col-span-2 tw-cursor-pointer"
[title]="'criticalApplications' | i18n" [title]="'criticalApplications' | i18n"
[cardMetrics]=" [cardMetrics]="
totalCriticalAppsCount === 0 totalCriticalAppsCount === 0
@@ -50,6 +56,6 @@
[showNavigationLink]="totalCriticalAppsAtRiskCount > 0" [showNavigationLink]="totalCriticalAppsAtRiskCount > 0"
> >
</dirt-activity-card> </dirt-activity-card>
</div> </li>
</div> </ul>
} }

View File

@@ -15,12 +15,18 @@ import { getById } from "@bitwarden/common/platform/misc";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { ActivityCardComponent } from "./activity-card.component"; import { ActivityCardComponent } from "./activity-card.component";
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
import { RiskInsightsTabType } from "./risk-insights.component"; import { RiskInsightsTabType } from "./risk-insights.component";
@Component({ @Component({
selector: "tools-all-activity", selector: "tools-all-activity",
imports: [ApplicationsLoadingComponent, SharedModule, ActivityCardComponent], imports: [
ApplicationsLoadingComponent,
SharedModule,
ActivityCardComponent,
PasswordChangeMetricComponent,
],
templateUrl: "./all-activity.component.html", templateUrl: "./all-activity.component.html",
}) })
export class AllActivityComponent implements OnInit { export class AllActivityComponent implements OnInit {
@@ -30,6 +36,7 @@ export class AllActivityComponent implements OnInit {
totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsAtRiskMemberCount = 0;
totalCriticalAppsCount = 0; totalCriticalAppsCount = 0;
totalCriticalAppsAtRiskCount = 0; totalCriticalAppsAtRiskCount = 0;
passwordChangeMetricHasProgressBar = false;
destroyRef = inject(DestroyRef); destroyRef = inject(DestroyRef);
@@ -51,6 +58,12 @@ export class AllActivityComponent implements OnInit {
this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; this.totalCriticalAppsCount = summary.totalCriticalApplicationCount;
this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount;
}); });
this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((hasProgressBar) => {
this.passwordChangeMetricHasProgressBar = hasProgressBar;
});
} }
} }

View File

@@ -101,6 +101,7 @@ export class CriticalApplicationsComponent implements OnInit {
this.applicationSummary = this.reportService.generateApplicationsSummary(applications); this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0;
this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary); this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary);
this.allActivitiesService.setAllAppsReportDetails(applications);
} }
}); });
} }