1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

[PM-30540] Access Intelligence action button updates (mark critical, assign tasks) (#18730)

Updates the buttons available in the Access Intelligence "Applications" tab.

- The "Mark as critical" button appears when at least 1 row is selected in the table, and if all selected applications are already marked critical, changes to a "Mark as not critical" button. This functionality allows Admins to either bulk mark critical applications, or bulk unmark critical applications.
- "Assign tasks" has been moved into this tab view, and now is only enabled when there are critical ciphers found without assigned password change tasks. A tooltip appears when hovering on the disabled state, informing the Admin that all tasks have already been assigned.
This commit is contained in:
Brad
2026-02-10 15:08:20 -08:00
committed by GitHub
parent cc03df4950
commit 0f5163453e
11 changed files with 282 additions and 91 deletions

View File

@@ -268,6 +268,42 @@
}
}
},
"numCriticalApplicationsMarkedSuccess": {
"message": "$COUNT$ applications marked critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"numApplicationsUnmarkedCriticalSuccess": {
"message": "$COUNT$ applications marked not critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"markAppCountAsCritical": {
"message": "Mark $COUNT$ as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"markAppCountAsNotCritical": {
"message": "Mark $COUNT$ as not critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"applicationsMarkedAsCriticalFail": {
"message": "Failed to mark applications as critical"
},
@@ -10148,6 +10184,9 @@
"assignTasks": {
"message": "Assign tasks"
},
"allTasksAssigned": {
"message": "All tasks have been assigned"
},
"assignSecurityTasksToMembers": {
"message": "Send notifications to change passwords"
},

View File

@@ -228,7 +228,7 @@ export class RiskInsightsOrchestratorService {
* @param criticalApplication Application name of the critical application to remove
* @returns
*/
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
removeCriticalApplications$(applicationsToUnmark: Set<string>): Observable<ReportState> {
this.logService.info(
"[RiskInsightsOrchestratorService] Removing critical applications from report",
);
@@ -245,11 +245,10 @@ export class RiskInsightsOrchestratorService {
throwError(() => Error("Tried to update critical applications without a report"));
}
// Create a set for quick lookup of the new critical apps
const existingApplicationData = report!.applicationData || [];
const updatedApplicationData = this._removeCriticalApplication(
const updatedApplicationData = this._removeCriticalApplications(
existingApplicationData,
criticalApplication,
applicationsToUnmark,
);
// Updated summary data after changing critical apps
@@ -917,12 +916,12 @@ export class RiskInsightsOrchestratorService {
}
// Toggles the isCritical flag on applications via criticalApplicationName
private _removeCriticalApplication(
private _removeCriticalApplications(
applicationData: OrganizationReportApplication[],
criticalApplication: string,
applicationsToUnmark: Set<string>,
): OrganizationReportApplication[] {
const updatedApplicationData = applicationData.map((application) => {
if (application.applicationName == criticalApplication) {
if (applicationsToUnmark.has(application.applicationName)) {
return { ...application, isCritical: false } as OrganizationReportApplication;
}
return application;

View File

@@ -263,8 +263,8 @@ export class RiskInsightsDataService {
return this.orchestrator.saveCriticalApplications$(selectedUrls);
}
removeCriticalApplication(hostname: string) {
return this.orchestrator.removeCriticalApplication$(hostname);
removeCriticalApplications(selectedUrls: Set<string>) {
return this.orchestrator.removeCriticalApplications$(selectedUrls);
}
saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) {

View File

@@ -59,7 +59,7 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
safeProvider({
provide: AccessIntelligenceSecurityTasksService,
useClass: AccessIntelligenceSecurityTasksService,
deps: [DefaultAdminTaskService, SecurityTasksApiService],
deps: [DefaultAdminTaskService, SecurityTasksApiService, RiskInsightsDataService],
}),
safeProvider({
provide: PasswordHealthService,

View File

@@ -20,7 +20,7 @@ import {
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks";
import {
ButtonModule,
@@ -57,10 +57,14 @@ export class PasswordChangeMetricComponent implements OnInit {
// Signal states
private readonly _tasks: Signal<SecurityTask[]> = signal<SecurityTask[]>([]);
private readonly _atRiskCipherIds: Signal<CipherId[]> = signal<CipherId[]>([]);
private readonly _hasCriticalApplications: Signal<boolean> = signal<boolean>(false);
private readonly _reportGeneratedAt: Signal<Date | undefined> = signal<Date | undefined>(
undefined,
private readonly _unassignedCipherIds = toSignal(
this.securityTasksService.unassignedCriticalCipherIds$,
{ initialValue: [] },
);
private readonly _atRiskCipherIds = toSignal(
this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$,
{ initialValue: [] },
);
// Computed properties
@@ -74,41 +78,11 @@ export class PasswordChangeMetricComponent implements OnInit {
return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0;
});
readonly unassignedCipherIds = computed<number>(() => {
const atRiskIds = this._atRiskCipherIds();
const tasks = this._tasks();
readonly unassignedCipherIds = computed(() => this._unassignedCipherIds().length);
if (tasks.length === 0) {
return atRiskIds.length;
}
const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending);
const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId));
const reportGeneratedAt = this._reportGeneratedAt();
const completedTasksAfterReportGeneration = reportGeneratedAt
? tasks.filter(
(task) =>
task.status === SecurityTaskStatus.Completed &&
new Date(task.revisionDate) >= reportGeneratedAt,
)
: [];
const completedTaskIds = new Set(
completedTasksAfterReportGeneration.map((task) => task.cipherId),
);
// find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task
const unassignedIds = atRiskIds.filter(
(id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id),
);
return unassignedIds.length;
});
readonly atRiskPasswordCount = computed<number>(() => {
readonly atRiskPasswordCount = computed(() => {
const atRiskIds = this._atRiskCipherIds();
const atRiskIdsSet = new Set(atRiskIds);
return atRiskIdsSet.size;
});
@@ -119,7 +93,7 @@ export class PasswordChangeMetricComponent implements OnInit {
if (this.tasksCount() === 0) {
return PasswordChangeView.NO_TASKS_ASSIGNED;
}
if (this.unassignedCipherIds() > 0) {
if (this._unassignedCipherIds().length > 0) {
return PasswordChangeView.NEW_TASKS_AVAILABLE;
}
return PasswordChangeView.PROGRESS;
@@ -133,10 +107,6 @@ export class PasswordChangeMetricComponent implements OnInit {
private toastService: ToastService,
) {
this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] });
this._atRiskCipherIds = toSignal(
this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$,
{ initialValue: [] },
);
this._hasCriticalApplications = toSignal(
this.riskInsightsDataService.criticalReportResults$.pipe(
map((report) => {
@@ -145,10 +115,6 @@ export class PasswordChangeMetricComponent implements OnInit {
),
{ initialValue: false },
);
this._reportGeneratedAt = toSignal(
this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)),
{ initialValue: undefined },
);
effect(() => {
const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS;
@@ -164,7 +130,7 @@ export class PasswordChangeMetricComponent implements OnInit {
try {
await this.securityTasksService.requestPasswordChangeForCriticalApplications(
this.organizationId(),
this._atRiskCipherIds(),
this._unassignedCipherIds(),
);
this.toastService.showToast({
message: this.i18nService.t("notifiedMembers"),

View File

@@ -21,27 +21,58 @@
>
</bit-chip-select>
<button
type="button"
[buttonType]="'primary'"
bitButton
class="tw-ml-auto"
[disabled]="!selectedUrls().size"
[loading]="markingAsCritical()"
(click)="markAppsAsCritical()"
>
<i class="bwi tw-mr-2" [ngClass]="selectedUrls().size ? 'bwi-star-f' : 'bwi-star'"></i>
{{ "markAppAsCritical" | i18n }}
</button>
<div class="tw-ml-auto tw-gap-4 tw-flex">
@if (selectedUrls().size > 0) {
@if (allSelectedAppsAreCritical()) {
<button
type="button"
[buttonType]="'primary'"
bitButton
class="tw-ml-auto"
[loading]="updatingCriticalApps()"
(click)="unmarkAppsAsCritical()"
data-testid="unmark-critical-button"
>
{{ "markAppCountAsNotCritical" | i18n: selectedUrls().size }}
</button>
} @else {
<button
type="button"
[buttonType]="'primary'"
bitButton
class="tw-ml-auto"
[loading]="updatingCriticalApps()"
(click)="markAppsAsCritical()"
data-testid="mark-critical-button"
>
{{ "markAppCountAsCritical" | i18n: selectedUrls().size }}
</button>
}
}
<button
type="button"
bitIconButton="bwi-download"
buttonType="main"
[label]="'downloadCSV' | i18n"
[disabled]="!dataSource.filteredData.length"
(click)="downloadApplicationsCSV()"
></button>
<button
bitButton
buttonType="secondary"
type="button"
[disabled]="!enableRequestPasswordChange()"
[bitTooltip]="!enableRequestPasswordChange() ? ('allTasksAssigned' | i18n) : null"
[addTooltipToDescribedby]="!enableRequestPasswordChange()"
(click)="requestPasswordChange()"
data-testid="assign-tasks-button"
>
<bit-icon class="tw-mr-2" name="bwi-envelope" />
{{ "assignTasks" | i18n }}
</button>
<button
type="button"
bitIconButton="bwi-download"
buttonType="main"
[label]="'downloadCSV' | i18n"
[disabled]="!dataSource.filteredData.length"
(click)="downloadApplicationsCSV()"
></button>
</div>
</div>
<app-table-row-scrollable-m11

View File

@@ -19,6 +19,7 @@ import { CipherId } from "@bitwarden/common/types/guid";
import { TableDataSource, ToastService } from "@bitwarden/components";
import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component";
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
import { ApplicationsComponent } from "./applications.component";
@@ -35,6 +36,7 @@ describe("ApplicationsComponent", () => {
let mockLogService: MockProxy<LogService>;
let mockToastService: MockProxy<ToastService>;
let mockDataService: MockProxy<RiskInsightsDataService>;
let mockSecurityTasksService: MockProxy<AccessIntelligenceSecurityTasksService>;
const reportStatus$ = new BehaviorSubject<ReportStatus>(ReportStatus.Complete);
const enrichedReportData$ = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
@@ -47,6 +49,7 @@ describe("ApplicationsComponent", () => {
appAtRiskMembers: null,
atRiskAppDetails: null,
});
const unassignedCriticalCipherIds$ = new BehaviorSubject([]);
beforeEach(async () => {
mockI18nService = mock<I18nService>();
@@ -54,6 +57,7 @@ describe("ApplicationsComponent", () => {
mockLogService = mock<LogService>();
mockToastService = mock<ToastService>();
mockDataService = mock<RiskInsightsDataService>();
mockSecurityTasksService = mock<AccessIntelligenceSecurityTasksService>();
mockI18nService.t.mockImplementation((key: string) => key);
@@ -65,6 +69,9 @@ describe("ApplicationsComponent", () => {
get: () => criticalReportResults$,
});
Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ });
Object.defineProperty(mockSecurityTasksService, "unassignedCriticalCipherIds$", {
get: () => unassignedCriticalCipherIds$,
});
await TestBed.configureTestingModule({
imports: [ApplicationsComponent, ReactiveFormsModule],
@@ -78,6 +85,7 @@ describe("ApplicationsComponent", () => {
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: (): string | null => null } } },
},
{ provide: AccessIntelligenceSecurityTasksService, useValue: mockSecurityTasksService },
],
}).compileComponents();

View File

@@ -7,10 +7,10 @@ import {
signal,
computed,
} from "@angular/core";
import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed, toObservable, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, debounceTime, startWith } from "rxjs";
import { combineLatest, debounceTime, EMPTY, map, startWith, switchMap } from "rxjs";
import { Security } from "@bitwarden/assets/svg";
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
@@ -22,6 +22,7 @@ import {
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
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,
IconButtonModule,
@@ -30,8 +31,10 @@ import {
SearchModule,
TableDataSource,
ToastService,
TooltipDirective,
TypographyModule,
ChipSelectComponent,
IconComponent,
} from "@bitwarden/components";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils";
@@ -42,6 +45,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip
import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component";
import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component";
import { ReportLoadingComponent } from "../shared/report-loading.component";
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
export const ApplicationFilterOption = {
All: "all",
@@ -70,6 +74,8 @@ export type ApplicationFilterOption =
ButtonModule,
ReactiveFormsModule,
ChipSelectComponent,
IconComponent,
TooltipDirective,
],
})
export class ApplicationsComponent implements OnInit {
@@ -86,13 +92,14 @@ export class ApplicationsComponent implements OnInit {
// Template driven properties
protected readonly selectedUrls = signal(new Set<string>());
protected readonly markingAsCritical = signal(false);
protected readonly updatingCriticalApps = signal(false);
protected readonly applicationSummary = signal<OrganizationReportSummary>(createNewSummaryData());
protected readonly criticalApplicationsCount = signal(0);
protected readonly totalApplicationsCount = signal(0);
protected readonly nonCriticalApplicationsCount = computed(() => {
return this.totalApplicationsCount() - this.criticalApplicationsCount();
});
protected readonly organizationId = signal<OrganizationId | undefined>(undefined);
// filter related properties
protected readonly selectedFilter = signal<ApplicationFilterOption>(ApplicationFilterOption.All);
@@ -112,14 +119,46 @@ export class ApplicationsComponent implements OnInit {
]);
protected readonly emptyTableExplanation = signal("");
readonly allSelectedAppsAreCritical = computed(() => {
if (!this.dataSource.filteredData || this.selectedUrls().size == 0) {
return false;
}
return this.dataSource.filteredData
.filter((row) => this.selectedUrls().has(row.applicationName))
.every((row) => row.isMarkedAsCritical);
});
protected readonly unassignedCipherIds = toSignal(
this.securityTasksService.unassignedCriticalCipherIds$,
{ initialValue: [] },
);
readonly enableRequestPasswordChange = computed(() => this.unassignedCipherIds().length > 0);
constructor(
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected securityTasksService: AccessIntelligenceSecurityTasksService,
) {}
async ngOnInit() {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap(async (orgId) => {
if (orgId) {
this.organizationId.set(orgId as OrganizationId);
} else {
return EMPTY;
}
}),
)
.subscribe();
this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (report) => {
if (report != null) {
@@ -193,12 +232,8 @@ export class ApplicationsComponent implements OnInit {
this.selectedFilter.set(value);
}
isMarkedAsCriticalItem(applicationName: string) {
return this.selectedUrls().has(applicationName);
}
markAppsAsCritical = async () => {
this.markingAsCritical.set(true);
this.updatingCriticalApps.set(true);
const count = this.selectedUrls().size;
this.dataService
@@ -209,10 +244,10 @@ export class ApplicationsComponent implements OnInit {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()),
message: this.i18nService.t("numCriticalApplicationsMarkedSuccess", count),
});
this.selectedUrls.set(new Set<string>());
this.markingAsCritical.set(false);
this.updatingCriticalApps.set(false);
},
error: () => {
this.toastService.showToast({
@@ -224,6 +259,65 @@ export class ApplicationsComponent implements OnInit {
});
};
unmarkAppsAsCritical = async () => {
this.updatingCriticalApps.set(true);
const appsToUnmark = this.selectedUrls();
this.dataService
.removeCriticalApplications(appsToUnmark)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.toastService.showToast({
message: this.i18nService.t(
"numApplicationsUnmarkedCriticalSuccess",
appsToUnmark.size,
),
variant: "success",
});
this.selectedUrls.set(new Set<string>());
this.updatingCriticalApps.set(false);
},
error: () => {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
},
});
};
async requestPasswordChange() {
const orgId = this.organizationId();
if (!orgId) {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
return;
}
try {
await this.securityTasksService.requestPasswordChangeForCriticalApplications(
orgId,
this.unassignedCipherIds(),
);
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"),
});
}
}
showAppAtRiskMembers = async (applicationName: string) => {
await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
};

View File

@@ -131,7 +131,7 @@ export class CriticalApplicationsComponent implements OnInit {
removeCriticalApplication = async (hostname: string) => {
this.dataService
.removeCriticalApplication(hostname)
.removeCriticalApplications(new Set<string>([hostname]))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {

View File

@@ -1,7 +1,10 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
RiskInsightsDataService,
SecurityTasksApiService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
@@ -13,12 +16,14 @@ describe("AccessIntelligenceSecurityTasksService", () => {
let service: AccessIntelligenceSecurityTasksService;
const defaultAdminTaskServiceMock = mock<DefaultAdminTaskService>();
const securityTasksApiServiceMock = mock<SecurityTasksApiService>();
const riskInsightsDataServiceMock = mock<RiskInsightsDataService>();
beforeEach(() => {
TestBed.configureTestingModule({});
service = new AccessIntelligenceSecurityTasksService(
defaultAdminTaskServiceMock,
securityTasksApiServiceMock,
riskInsightsDataServiceMock,
);
});

View File

@@ -1,8 +1,10 @@
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, combineLatest, Observable } from "rxjs";
import { map, shareReplay } from "rxjs/operators";
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
@@ -14,10 +16,57 @@ export class AccessIntelligenceSecurityTasksService {
private _tasksSubject$ = new BehaviorSubject<SecurityTask[]>([]);
tasks$ = this._tasksSubject$.asObservable();
/**
* Observable stream of unassigned critical cipher IDs.
* Returns cipher IDs from critical applications that don't have an associated task
* (either pending or completed after the report was generated).
*/
readonly unassignedCriticalCipherIds$: Observable<CipherId[]>;
constructor(
private adminTaskService: DefaultAdminTaskService,
private securityTasksApiService: SecurityTasksApiService,
) {}
private riskInsightsDataService: RiskInsightsDataService,
) {
this.unassignedCriticalCipherIds$ = combineLatest([
this.tasks$,
this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$,
this.riskInsightsDataService.enrichedReportData$,
]).pipe(
map(([tasks, atRiskCipherIds, reportData]) => {
// If no tasks exist, return all at-risk cipher IDs
if (tasks.length === 0) {
return atRiskCipherIds;
}
// Get in-progress tasks (awaiting password reset)
const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending);
const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId));
// Get completed tasks after report generation
const reportGeneratedAt = reportData?.creationDate;
const completedTasksAfterReportGeneration = reportGeneratedAt
? tasks.filter(
(task) =>
task.status === SecurityTaskStatus.Completed &&
new Date(task.revisionDate) >= reportGeneratedAt,
)
: [];
const completedTaskIds = new Set(
completedTasksAfterReportGeneration.map((task) => task.cipherId),
);
// Filter out cipher IDs that have a corresponding in-progress or completed task
return atRiskCipherIds.filter(
(id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id),
);
}),
shareReplay({
bufferSize: 1,
refCount: true,
}),
);
}
/**
* Gets security task metrics for the given organization