1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

PM-26508 when password change is requested, the all-activity table count will change (#16753)

This commit is contained in:
Vijay Oommen
2025-10-08 14:15:59 -05:00
committed by GitHub
parent d747dc30a2
commit ab995045fd
6 changed files with 227 additions and 87 deletions

View File

@@ -35,6 +35,9 @@ export class AllActivitiesService {
passwordChangeProgressMetricHasProgressBar$ =
this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable();
private taskCreatedCountSubject$ = new BehaviorSubject<number>(0);
taskCreatedCount$ = this.taskCreatedCountSubject$.asObservable();
constructor(private dataService: RiskInsightsDataService) {
// All application summary changes
this.dataService.reportResults$.subscribe((report) => {
@@ -85,4 +88,8 @@ export class AllActivitiesService {
setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) {
this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar);
}
setTaskCreatedCount(count: number) {
this.taskCreatedCountSubject$.next(count);
}
}

View File

@@ -19,12 +19,17 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
import { RiskInsightsComponent } from "./risk-insights.component";
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
@NgModule({
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule],
@@ -89,6 +94,11 @@ import { RiskInsightsComponent } from "./risk-insights.component";
useClass: SecurityTasksApiService,
deps: [ApiService],
}),
safeProvider({
provide: AccessIntelligenceSecurityTasksService,
useClass: AccessIntelligenceSecurityTasksService,
deps: [AllActivitiesService, DefaultAdminTaskService, ToastService, I18nService],
}),
],
})
export class AccessIntelligenceModule {}

View File

@@ -10,18 +10,11 @@ import {
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 { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
export const RenderMode = {
noCriticalApps: "noCriticalApps",
@@ -34,7 +27,7 @@ export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
selector: "dirt-password-change-metric",
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
templateUrl: "./password-change-metric.component.html",
providers: [DefaultAdminTaskService],
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
})
export class PasswordChangeMetricComponent implements OnInit {
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
@@ -50,10 +43,10 @@ export class PasswordChangeMetricComponent implements OnInit {
renderMode: RenderMode = "noCriticalApps";
async ngOnInit(): Promise<void> {
this.activatedRoute.paramMap
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
.pipe(
switchMap((paramMap) => {
const orgId = paramMap.get("organizationId");
switchMap(([params, _]) => {
const orgId = params.get("organizationId");
if (orgId) {
this.organizationId = orgId as OrganizationId;
return this.securityTasksApiService.getTaskMetrics(this.organizationId);
@@ -110,9 +103,7 @@ export class PasswordChangeMetricComponent implements OnInit {
private activatedRoute: ActivatedRoute,
private securityTasksApiService: SecurityTasksApiService,
private allActivitiesService: AllActivitiesService,
private adminTaskService: DefaultAdminTaskService,
protected toastService: ToastService,
protected i18nService: I18nService,
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
) {}
get completedPercent(): number {
@@ -161,44 +152,9 @@ export class PasswordChangeMetricComponent implements OnInit {
}
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;
await this.accessIntelligenceSecurityTasksService.assignTasks(
this.organizationId,
this.allApplicationsDetails,
);
}
}

View File

@@ -9,24 +9,25 @@ import { debounceTime, EMPTY, map, switchMap } from "rxjs";
import { Security } from "@bitwarden/assets/svg";
import {
ApplicationHealthReportDetailEnriched,
CriticalAppsService,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
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 { OrganizationId } from "@bitwarden/common/types/guid";
import { NoItemsModule, SearchModule, TableDataSource, ToastService } from "@bitwarden/components";
import { CardComponent } from "@bitwarden/dirt-card";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { CreateTasksRequest } from "../../vault/services/abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component";
import { RiskInsightsTabType } from "./risk-insights.component";
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
@Component({
selector: "dirt-critical-applications",
@@ -40,7 +41,7 @@ import { RiskInsightsTabType } from "./risk-insights.component";
SharedModule,
AppTableRowScrollableComponent,
],
providers: [DefaultAdminTaskService],
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
})
export class CriticalApplicationsComponent implements OnInit {
private destroyRef = inject(DestroyRef);
@@ -60,8 +61,10 @@ export class CriticalApplicationsComponent implements OnInit {
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected i18nService: I18nService,
private adminTaskService: DefaultAdminTaskService,
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
@@ -128,33 +131,12 @@ export class CriticalApplicationsComponent implements OnInit {
};
async requestPasswordChange() {
const apps = this.dataSource.data;
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"),
});
} catch {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
}
await this.accessIntelligenceSecurityTasksService.assignTasks(
this.organizationId,
this.dataSource.data,
);
}
showAppAtRiskMembers = async (applicationName: string) => {
await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
};

View File

@@ -0,0 +1,119 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import {
AllActivitiesService,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service";
describe("AccessIntelligenceSecurityTasksService", () => {
let service: AccessIntelligenceSecurityTasksService;
const defaultAdminTaskServiceSpy = mock<DefaultAdminTaskService>();
const allActivitiesServiceSpy = mock<AllActivitiesService>();
const toastServiceSpy = mock<ToastService>();
const i18nServiceSpy = mock<I18nService>();
beforeEach(() => {
TestBed.configureTestingModule({});
service = new AccessIntelligenceSecurityTasksService(
allActivitiesServiceSpy,
defaultAdminTaskServiceSpy,
toastServiceSpy,
i18nServiceSpy,
);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
describe("assignTasks", () => {
it("should call requestPasswordChange and setTaskCreatedCount", async () => {
const organizationId = "org-1" as OrganizationId;
const apps = [
{
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid1"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
];
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2);
await service.assignTasks(organizationId, apps);
expect(spy).toHaveBeenCalledWith(organizationId, apps);
expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2);
});
});
describe("requestPasswordChange", () => {
it("should create tasks for distinct cipher ids and show success toast", async () => {
const organizationId = "org-2" as OrganizationId;
const apps = [
{
atRiskPasswordCount: 2,
atRiskCipherIds: ["cid1", "cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
{
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
];
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
i18nServiceSpy.t.mockImplementation((key) => key);
const result = await service.requestPasswordChange(organizationId, apps);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [
{ cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential },
{ cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential },
]);
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({
message: "notifiedMembers",
variant: "success",
title: "success",
});
expect(result).toBe(2);
});
it("should show error toast and return 0 if bulkCreateTasks throws", async () => {
const organizationId = "org-3" as OrganizationId;
const apps = [
{
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid3"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
];
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
i18nServiceSpy.t.mockImplementation((key) => key);
const result = await service.requestPasswordChange(organizationId, apps);
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({
message: "unexpectedError",
variant: "error",
title: "error",
});
expect(result).toBe(0);
});
it("should not create any tasks if no apps have atRiskPasswordCount > 0", async () => {
const organizationId = "org-4" as OrganizationId;
const apps = [
{
atRiskPasswordCount: 0,
atRiskCipherIds: ["cid4"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
];
const result = await service.requestPasswordChange(organizationId, apps);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0);
});
});
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from "@angular/core";
import {
AllActivitiesService,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} 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 { ToastService } from "@bitwarden/components";
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
@Injectable()
export class AccessIntelligenceSecurityTasksService {
constructor(
private allActivitiesService: AllActivitiesService,
private adminTaskService: DefaultAdminTaskService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
async assignTasks(
organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
) {
const taskCount = await this.requestPasswordChange(organizationId, apps);
this.allActivitiesService.setTaskCreatedCount(taskCount);
}
// TODO: this method is shared between here and critical-applications.component.ts
async requestPasswordChange(
organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
): Promise<number> {
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(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;
}
}