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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user