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

[PM-27762] Activity Tab - Password Change Progress - Assign tasks for new passwords (#17268)

* Update type guard for cipher ids on reports

* Update report model cipher id type and mock data

* Update security tasks api service to have copied getAllTasks function from the vault team

* Expose critical application at risk cipher ids

* Update cipher id type in report service. Update all activities service to move task function to task service

* Update module

* Update organization id sharing through components instead of multiple route fetchings

* Update view type of password change widget. Update variables to be signals. Refactor logic for calculations based on individual tasks

* Update usage of request password change function

* Update security tasks service to manage tasks

* Remove unused variable

* Alphabetized functions, added documentation. Removed injectable decorator

* Alphabetize constructor params for password health service

* Update providers

* Address NaN case on percentage. Address obsolete type casting to CipherID and any other claude comments

* Fix dependency array in test case
This commit is contained in:
Leslie Tilton
2025-11-07 12:04:05 -06:00
committed by GitHub
parent 4f9ae78598
commit ec07a5391a
23 changed files with 497 additions and 537 deletions

View File

@@ -1,3 +1,5 @@
import { CipherId } from "@bitwarden/common/types/guid";
import { import {
ApplicationHealthReportDetail, ApplicationHealthReportDetail,
MemberDetails, MemberDetails,
@@ -10,7 +12,6 @@ import {
createValidator, createValidator,
isBoolean, isBoolean,
isBoundedString, isBoundedString,
isBoundedStringArray,
isBoundedStringOrNull, isBoundedStringOrNull,
isBoundedPositiveNumber, isBoundedPositiveNumber,
BOUNDED_ARRAY_MAX_LENGTH, BOUNDED_ARRAY_MAX_LENGTH,
@@ -33,6 +34,10 @@ export const isMemberDetails = createValidator<MemberDetails>({
}); });
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails); export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
export function isCipherId(value: unknown): value is CipherId {
return value == null || isBoundedString(value);
}
export const isCipherIdArray = createBoundedArrayGuard(isCipherId);
/** /**
* Type guard to validate ApplicationHealthReportDetail structure * Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability * Exported for testability
@@ -40,11 +45,11 @@ export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
*/ */
export const isApplicationHealthReportDetail = createValidator<ApplicationHealthReportDetail>({ export const isApplicationHealthReportDetail = createValidator<ApplicationHealthReportDetail>({
applicationName: isBoundedString, applicationName: isBoundedString,
atRiskCipherIds: isBoundedStringArray, atRiskCipherIds: isCipherIdArray,
atRiskMemberCount: isBoundedPositiveNumber, atRiskMemberCount: isBoundedPositiveNumber,
atRiskMemberDetails: isMemberDetailsArray, atRiskMemberDetails: isMemberDetailsArray,
atRiskPasswordCount: isBoundedPositiveNumber, atRiskPasswordCount: isBoundedPositiveNumber,
cipherIds: isBoundedStringArray, cipherIds: isCipherIdArray,
memberCount: isBoundedPositiveNumber, memberCount: isBoundedPositiveNumber,
memberDetails: isMemberDetailsArray, memberDetails: isMemberDetailsArray,
passwordCount: isBoundedPositiveNumber, passwordCount: isBoundedPositiveNumber,

View File

@@ -1,5 +1,6 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -13,11 +14,14 @@ import {
PasswordHealthData, PasswordHealthData,
} from "../report-models"; } from "../report-models";
const mockCipherId1 = "cipher-1" as CipherId;
const mockCipherId2 = "cipher-2" as CipherId;
const mockApplication1: ApplicationHealthReportDetail = { const mockApplication1: ApplicationHealthReportDetail = {
applicationName: "application1.com", applicationName: "application1.com",
passwordCount: 2, passwordCount: 2,
atRiskPasswordCount: 1, atRiskPasswordCount: 1,
atRiskCipherIds: ["cipher-1"], atRiskCipherIds: [mockCipherId1],
memberCount: 2, memberCount: 2,
atRiskMemberCount: 1, atRiskMemberCount: 1,
memberDetails: [ memberDetails: [
@@ -33,10 +37,10 @@ const mockApplication1: ApplicationHealthReportDetail = {
userGuid: "user-id-2", userGuid: "user-id-2",
userName: "tom", userName: "tom",
email: "tom2@application1.com", email: "tom2@application1.com",
cipherId: "cipher-2", cipherId: mockCipherId2,
}, },
], ],
cipherIds: ["cipher-1", "cipher-2"], cipherIds: [mockCipherId1, mockCipherId2],
}; };
const mockApplication2: ApplicationHealthReportDetail = { const mockApplication2: ApplicationHealthReportDetail = {

View File

@@ -1,7 +1,7 @@
import { Opaque } from "type-fest"; import { Opaque } from "type-fest";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components"; import { BadgeVariant } from "@bitwarden/components";
@@ -79,12 +79,12 @@ export type ApplicationHealthReportDetail = {
applicationName: string; applicationName: string;
passwordCount: number; passwordCount: number;
atRiskPasswordCount: number; atRiskPasswordCount: number;
atRiskCipherIds: string[]; atRiskCipherIds: CipherId[];
memberCount: number; memberCount: number;
atRiskMemberCount: number; atRiskMemberCount: number;
memberDetails: MemberDetails[]; memberDetails: MemberDetails[];
atRiskMemberDetails: MemberDetails[]; atRiskMemberDetails: MemberDetails[];
cipherIds: string[]; cipherIds: CipherId[];
}; };
// -------------------- Password Health Report Models -------------------- // -------------------- Password Health Report Models --------------------

View File

@@ -1,7 +1,14 @@
import { from, Observable } from "rxjs"; import { from, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import {
SecurityTask,
SecurityTaskData,
SecurityTaskResponse,
SecurityTaskStatus,
} from "@bitwarden/common/vault/tasks";
export type TaskMetrics = { export type TaskMetrics = {
completedTasks: number; completedTasks: number;
@@ -22,4 +29,29 @@ export class SecurityTasksApiService {
return from(dbResponse as Promise<TaskMetrics>); return from(dbResponse as Promise<TaskMetrics>);
} }
// Could not import from @bitwarden/bit-web
// Copying from /bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts
async getAllTasks(
organizationId: OrganizationId,
status?: SecurityTaskStatus | undefined,
): Promise<SecurityTask[]> {
const queryParams = new URLSearchParams();
queryParams.append("organizationId", organizationId);
if (status !== undefined) {
queryParams.append("status", status.toString());
}
const r = await this.apiService.send(
"GET",
`/tasks/organization?${queryParams.toString()}`,
null,
true,
true,
);
const response = new ListResponse(r, SecurityTaskResponse);
return response.data.map((d) => new SecurityTask(new SecurityTaskData(d)));
}
} }

View File

@@ -28,7 +28,7 @@ describe("PasswordHealthService", () => {
auditService.passwordLeaked.mockImplementation((password: string) => auditService.passwordLeaked.mockImplementation((password: string) =>
Promise.resolve(password === "leaked" ? 2 : 0), Promise.resolve(password === "leaked" ? 2 : 0),
); );
service = new PasswordHealthService(passwordStrengthService, auditService); service = new PasswordHealthService(auditService, passwordStrengthService);
// Setup mock data // Setup mock data
mockValidCipher = mock<CipherView>({ mockValidCipher = mock<CipherView>({

View File

@@ -14,8 +14,8 @@ import {
export class PasswordHealthService { export class PasswordHealthService {
constructor( constructor(
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private auditService: AuditService, private auditService: AuditService,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
) {} ) {}
/** /**

View File

@@ -32,7 +32,7 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -89,6 +89,10 @@ export class RiskInsightsOrchestratorService {
private _hasCiphersSubject$ = new BehaviorSubject<boolean | null>(null); private _hasCiphersSubject$ = new BehaviorSubject<boolean | null>(null);
hasCiphers$ = this._hasCiphersSubject$.asObservable(); hasCiphers$ = this._hasCiphersSubject$.asObservable();
private _criticalApplicationAtRiskCipherIdsSubject$ = new BehaviorSubject<CipherId[]>([]);
readonly criticalApplicationAtRiskCipherIds$ =
this._criticalApplicationAtRiskCipherIdsSubject$.asObservable();
// ------------------------- Report Variables ---------------- // ------------------------- Report Variables ----------------
private _rawReportDataSubject = new BehaviorSubject<ReportState>({ private _rawReportDataSubject = new BehaviorSubject<ReportState>({
status: ReportStatus.Initializing, status: ReportStatus.Initializing,
@@ -1150,10 +1154,42 @@ export class RiskInsightsOrchestratorService {
this._reportStateSubscription = mergedReportState$ this._reportStateSubscription = mergedReportState$
.pipe(takeUntil(this._destroy$)) .pipe(takeUntil(this._destroy$))
.subscribe((state) => { .subscribe((state) => {
// Update the raw report data subject
this._rawReportDataSubject.next(state.reportState); this._rawReportDataSubject.next(state.reportState);
// Update the critical application at risk cipher ids for exposure
const reportState = state.reportState?.data;
if (reportState) {
const criticalApplicationAtRiskCipherIds = this._getCriticalApplicationCipherIds(
reportState.reportData || [],
reportState.applicationData || [],
);
this._criticalApplicationAtRiskCipherIdsSubject$.next(criticalApplicationAtRiskCipherIds);
}
}); });
} }
// Gets the unique cipher IDs that are marked at risk in critical applications
private _getCriticalApplicationCipherIds(
applications: ApplicationHealthReportDetail[],
applicationData: OrganizationReportApplication[],
): CipherId[] {
const foundCipherIds = applications
.map((app) => {
const isCriticalApplication = this.reportService.isCriticalApplication(
app,
applicationData,
);
return isCriticalApplication ? app.atRiskCipherIds : [];
})
.flat();
// Use a set to ensure uniqueness
const uniqueCipherIds = new Set<CipherId>([...foundCipherIds]);
return [...uniqueCipherIds];
}
// Setup the user ID observable to track the current user // Setup the user ID observable to track the current user
private _setupUserId() { private _setupUserId() {
// Watch userId changes // Watch userId changes

View File

@@ -1,7 +1,12 @@
import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs"; import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; import {
CipherId,
OrganizationId,
OrganizationReportId,
UserId,
} from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers"; import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
@@ -63,7 +68,7 @@ export class RiskInsightsReportService {
): Map<string, CipherView[]> { ): Map<string, CipherView[]> {
const cipherMap = new Map<string, CipherView[]>(); const cipherMap = new Map<string, CipherView[]>();
applications.forEach((app) => { applications.forEach((app) => {
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id)); const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id as CipherId));
cipherMap.set(app.applicationName, filteredCiphers); cipherMap.set(app.applicationName, filteredCiphers);
}); });
return cipherMap; return cipherMap;
@@ -346,7 +351,7 @@ export class RiskInsightsReportService {
): ApplicationHealthReportDetail { ): ApplicationHealthReportDetail {
return { return {
applicationName: application, applicationName: application,
cipherIds: [cipherReport.cipher.id], cipherIds: [cipherReport.cipher.id as CipherId],
passwordCount: 1, passwordCount: 1,
memberDetails: [...cipherReport.cipherMembers], memberDetails: [...cipherReport.cipherMembers],
memberCount: cipherReport.cipherMembers.length, memberCount: cipherReport.cipherMembers.length,
@@ -367,7 +372,7 @@ export class RiskInsightsReportService {
memberDetails: getUniqueMembers( memberDetails: getUniqueMembers(
existingReport.memberDetails.concat(newCipherReport.cipherMembers), existingReport.memberDetails.concat(newCipherReport.cipherMembers),
), ),
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id as CipherId),
}; };
} }
@@ -377,7 +382,7 @@ export class RiskInsightsReportService {
); );
return { return {
atRiskPasswordCount: report.atRiskPasswordCount + 1, atRiskPasswordCount: report.atRiskPasswordCount + 1,
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id as CipherId),
atRiskMemberDetails, atRiskMemberDetails,
atRiskMemberCount: atRiskMemberDetails.length, atRiskMemberCount: atRiskMemberDetails.length,
}; };

View File

@@ -11,7 +11,6 @@ export class AllActivitiesService {
/// and critical applications. /// and critical applications.
/// Going forward, this class can be simplified by using the RiskInsightsDataService /// Going forward, this class can be simplified by using the RiskInsightsDataService
/// as it contains the application summary data. /// as it contains the application summary data.
private reportSummarySubject$ = new BehaviorSubject<OrganizationReportSummary>({ private reportSummarySubject$ = new BehaviorSubject<OrganizationReportSummary>({
totalMemberCount: 0, totalMemberCount: 0,
totalCriticalMemberCount: 0, totalCriticalMemberCount: 0,
@@ -31,12 +30,8 @@ export class AllActivitiesService {
private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0); private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0);
atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable(); atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable();
private passwordChangeProgressMetricHasProgressBarSubject$ = new BehaviorSubject<boolean>(false); private extendPasswordChangeWidgetSubject$ = new BehaviorSubject<boolean>(false);
passwordChangeProgressMetricHasProgressBar$ = extendPasswordChangeWidget$ = this.extendPasswordChangeWidgetSubject$.asObservable();
this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable();
private taskCreatedCountSubject$ = new BehaviorSubject<number>(0);
taskCreatedCount$ = this.taskCreatedCountSubject$.asObservable();
constructor(private dataService: RiskInsightsDataService) { constructor(private dataService: RiskInsightsDataService) {
// All application summary changes // All application summary changes
@@ -91,11 +86,7 @@ export class AllActivitiesService {
this.allApplicationsDetailsSubject$.next(applications); this.allApplicationsDetailsSubject$.next(applications);
} }
setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) { setExtendPasswordWidget(hasProgressBar: boolean) {
this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar); this.extendPasswordChangeWidgetSubject$.next(hasProgressBar);
}
setTaskCreatedCount(count: number) {
this.taskCreatedCountSubject$.next(count);
} }
} }

View File

@@ -1,7 +1,7 @@
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs"; import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
import { distinctUntilChanged, map } from "rxjs/operators"; import { distinctUntilChanged, map } from "rxjs/operators";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers"; import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
import { import {
@@ -39,6 +39,7 @@ export class RiskInsightsDataService {
readonly isGeneratingReport$: Observable<boolean> = of(false); readonly isGeneratingReport$: Observable<boolean> = of(false);
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null); readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
readonly hasCiphers$: Observable<boolean | null> = of(null); readonly hasCiphers$: Observable<boolean | null> = of(null);
readonly criticalApplicationAtRiskCipherIds$: Observable<CipherId[]> = of([]);
readonly reportProgress$: Observable<ReportProgress | null> = of(null); readonly reportProgress$: Observable<ReportProgress | null> = of(null);
// New applications that need review (reviewedDate === null) // New applications that need review (reviewedDate === null)
@@ -64,6 +65,8 @@ export class RiskInsightsDataService {
this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
this.criticalReportResults$ = this.orchestrator.criticalReportResults$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
this.newApplications$ = this.orchestrator.newApplications$; this.newApplications$ = this.orchestrator.newApplications$;
this.criticalApplicationAtRiskCipherIds$ =
this.orchestrator.criticalApplicationAtRiskCipherIds$;
this.reportProgress$ = this.orchestrator.reportProgress$; this.reportProgress$ = this.orchestrator.reportProgress$;
this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged()); this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged());

View File

@@ -20,10 +20,8 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; 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 { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@@ -37,22 +35,37 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
@NgModule({ @NgModule({
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent], imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
providers: [ providers: [
safeProvider(DefaultAdminTaskService), safeProvider({
provide: CriticalAppsApiService,
useClass: CriticalAppsApiService,
deps: [ApiService],
}),
safeProvider({ safeProvider({
provide: MemberCipherDetailsApiService, provide: MemberCipherDetailsApiService,
useClass: MemberCipherDetailsApiService, useClass: MemberCipherDetailsApiService,
deps: [ApiService], deps: [ApiService],
}), }),
safeProvider({
provide: PasswordHealthService,
useClass: PasswordHealthService,
deps: [PasswordStrengthServiceAbstraction, AuditService],
}),
safeProvider({ safeProvider({
provide: RiskInsightsApiService, provide: RiskInsightsApiService,
useClass: RiskInsightsApiService, useClass: RiskInsightsApiService,
deps: [ApiService], deps: [ApiService],
}), }),
safeProvider({
provide: SecurityTasksApiService,
useClass: SecurityTasksApiService,
deps: [ApiService],
}),
safeProvider(DefaultAdminTaskService),
safeProvider({
provide: AccessIntelligenceSecurityTasksService,
useClass: AccessIntelligenceSecurityTasksService,
deps: [DefaultAdminTaskService, SecurityTasksApiService],
}),
safeProvider({
provide: PasswordHealthService,
useClass: PasswordHealthService,
deps: [AuditService, PasswordStrengthServiceAbstraction],
}),
safeProvider({ safeProvider({
provide: RiskInsightsReportService, provide: RiskInsightsReportService,
useClass: RiskInsightsReportService, useClass: RiskInsightsReportService,
@@ -86,26 +99,11 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
useClass: CriticalAppsService, useClass: CriticalAppsService,
deps: [KeyService, EncryptService, CriticalAppsApiService], deps: [KeyService, EncryptService, CriticalAppsApiService],
}), }),
safeProvider({
provide: CriticalAppsApiService,
useClass: CriticalAppsApiService,
deps: [ApiService],
}),
safeProvider({ safeProvider({
provide: AllActivitiesService, provide: AllActivitiesService,
useClass: AllActivitiesService, useClass: AllActivitiesService,
deps: [RiskInsightsDataService], deps: [RiskInsightsDataService],
}), }),
safeProvider({
provide: SecurityTasksApiService,
useClass: SecurityTasksApiService,
deps: [ApiService],
}),
safeProvider({
provide: AccessIntelligenceSecurityTasksService,
useClass: AccessIntelligenceSecurityTasksService,
deps: [AllActivitiesService, DefaultAdminTaskService, ToastService, I18nService],
}),
], ],
}) })
export class AccessIntelligenceModule {} export class AccessIntelligenceModule {}

View File

@@ -5,75 +5,80 @@
{{ "passwordChangeProgress" | i18n }} {{ "passwordChangeProgress" | i18n }}
</div> </div>
@if (renderMode === renderModes.noCriticalApps) { @switch (currentView()) {
<div class="tw-items-start tw-mb-2"> @case (PasswordChangeViewEnum.EMPTY) {
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span> <div class="tw-items-start tw-mb-2">
</div> <span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{ "onceYouReviewApps" | 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">{{
hasExistingTasks
? ("newPasswordsAtRisk" | i18n: newAtRiskPasswordsCount)
: ("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>
</div>
<bit-progress
[showText]="false"
size="small"
bgColor="primary"
[barWidth]="completedPercent"
[ariaLabel]="'passwordChangeProgressBar' | i18n"
>
</bit-progress>
<!-- TODO: Implement reminder functionality --> <div class="tw-items-baseline tw-gap-2">
<!-- <div class="tw-items-start tw-mt-4 tw-gap-4"> <span bitTypography="body2">{{ "onceYouReviewApps" | i18n }}</span>
<button bitButton type="button" buttonType="secondary"> </div>
<i class="bwi bwi-envelope" aria-hidden="true"></i> }
{{ "sendReminders" | i18n }}
</button> @case (PasswordChangeViewEnum.NO_TASKS_ASSIGNED) {
</div> --> <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: atRiskPasswordCount()
}}</span>
</div>
@if (atRiskPasswordCount() > 0) {
<div class="tw-mt-4">
<button bitButton buttonType="secondary" type="button" (click)="assignTasks()">
<i class="bwi bwi-envelope tw-mr-2"></i>
{{ "assignTasks" | i18n }}
</button>
</div>
}
}
@case (PasswordChangeViewEnum.NEW_TASKS_AVAILABLE) {
<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">{{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }}</span>
</div>
<div class="tw-mt-4">
<button bitButton buttonType="secondary" type="button" (click)="assignTasks()">
<i class="bwi bwi-envelope tw-mr-2"></i>
{{ "assignTasks" | i18n }}
</button>
</div>
}
@case (PasswordChangeViewEnum.PROGRESS) {
<div class="tw-items-start tw-mb-2">
<span bitTypography="h3">{{ "percentageCompleted" | i18n: completedTasksPercent() }}</span>
</div>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{
"securityTasksCompleted" | i18n: completedTasksCount() : tasksCount()
}}</span>
</div>
<div class="tw-mt-4">
<div class="tw-flex tw-justify-between">
<div bitTypography="body2">{{ completedTasksCount() }}</div>
<div bitTypography="body2">{{ tasksCount() }}</div>
</div>
</div>
<bit-progress
[showText]="false"
size="small"
bgColor="primary"
[barWidth]="completedTasksPercent()"
[ariaLabel]="'passwordChangeProgressBar' | i18n"
>
</bit-progress>
}
} }
</div> </div>

View File

@@ -1,197 +1,169 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Injector,
OnInit, OnInit,
Signal,
computed,
effect,
inject, inject,
input,
signal,
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router"; import { map } from "rxjs";
import { switchMap, of, BehaviorSubject, combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { import {
AllActivitiesService, AllActivitiesService,
ApplicationHealthReportDetailEnriched, RiskInsightsDataService,
SecurityTasksApiService,
TaskMetrics,
OrganizationReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks";
import {
ButtonModule,
ProgressModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
import { RenderMode } from "../../models/activity.models";
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
export const PasswordChangeView = {
EMPTY: "empty",
NO_TASKS_ASSIGNED: "noTasksAssigned",
NEW_TASKS_AVAILABLE: "newTasks",
PROGRESS: "progress",
} as const;
export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof PasswordChangeView];
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-password-change-metric", selector: "dirt-password-change-metric",
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
templateUrl: "./password-change-metric.component.html", templateUrl: "./password-change-metric.component.html",
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
}) })
export class PasswordChangeMetricComponent implements OnInit { export class PasswordChangeMetricComponent implements OnInit {
PasswordChangeViewEnum = PasswordChangeView;
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 }); // Inputs
private completedTasks: number = 0; // Prefer component input since route param controls UI state
private totalTasks: number = 0; readonly organizationId = input.required<OrganizationId>();
private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = [];
atRiskAppsCount: number = 0; // Signal states
atRiskPasswordsCount: number = 0; private readonly _tasks: Signal<SecurityTask[]> = signal<SecurityTask[]>([]);
private organizationId!: OrganizationId; private readonly _atRiskCipherIds: Signal<CipherId[]> = signal<CipherId[]>([]);
renderMode: RenderMode = "noCriticalApps"; private readonly _hasCriticalApplications: Signal<boolean> = signal<boolean>(false);
// Computed properties (formerly getters) - updated when data changes // Computed properties
protected completedPercent = 0; readonly tasksCount = computed(() => this._tasks().length);
protected completedTasksCount = 0; readonly completedTasksCount = computed(
protected totalTasksCount = 0; () => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length,
protected canAssignTasks = false; );
protected hasExistingTasks = false; readonly uncompletedTasksCount = computed(
protected newAtRiskPasswordsCount = 0; () => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length,
);
readonly completedTasksPercent = computed(() => {
const total = this.tasksCount();
// Account for case where there are no tasks to avoid NaN
return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0;
});
readonly atRiskPasswordCount = computed<number>(() => {
const atRiskIds = this._atRiskCipherIds();
const tasks = this._tasks();
if (tasks.length === 0) {
return atRiskIds.length;
}
const assignedIdSet = new Set(tasks.map((task) => task.cipherId));
const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id));
return unassignedIds.length;
});
readonly currentView = computed<PasswordChangeView>(() => {
if (!this._hasCriticalApplications()) {
return PasswordChangeView.EMPTY;
}
if (this.tasksCount() === 0) {
return PasswordChangeView.NO_TASKS_ASSIGNED;
}
if (this.atRiskPasswordCount() > 0) {
return PasswordChangeView.NEW_TASKS_AVAILABLE;
}
return PasswordChangeView.PROGRESS;
});
constructor( constructor(
private activatedRoute: ActivatedRoute,
private securityTasksApiService: SecurityTasksApiService,
private allActivitiesService: AllActivitiesService, private allActivitiesService: AllActivitiesService,
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, private i18nService: I18nService,
private cdr: ChangeDetectorRef, private injector: Injector,
) {} private riskInsightsDataService: RiskInsightsDataService,
protected securityTasksService: AccessIntelligenceSecurityTasksService,
private toastService: ToastService,
) {
// Setup the _tasks signal by manually passing in the injector
this._tasks = toSignal(this.securityTasksService.tasks$, {
initialValue: [],
injector: this.injector,
});
// Setup the _atRiskCipherIds signal by manually passing in the injector
this._atRiskCipherIds = toSignal(
this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$,
{
initialValue: [],
injector: this.injector,
},
);
this._hasCriticalApplications = toSignal(
this.riskInsightsDataService.criticalReportResults$.pipe(
takeUntilDestroyed(this.destroyRef),
map((report) => {
return report != null && (report.reportData?.length ?? 0) > 0;
}),
),
{
initialValue: false,
injector: this.injector,
},
);
effect(() => {
const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS;
this.allActivitiesService.setExtendPasswordWidget(isShowingProgress);
});
}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) await this.securityTasksService.loadTasks(this.organizationId());
.pipe(
switchMap(([params, _]) => {
const orgId = params.get("organizationId");
if (orgId) {
this.organizationId = orgId as OrganizationId;
return this.securityTasksApiService.getTaskMetrics(this.organizationId);
}
return of({ totalTasks: 0, completedTasks: 0 });
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((metrics) => {
this.taskMetrics$.next(metrics);
this.cdr.markForCheck();
});
combineLatest([
this.taskMetrics$,
this.allActivitiesService.reportSummary$,
this.allActivitiesService.atRiskPasswordsCount$,
this.allActivitiesService.allApplicationsDetails$,
])
.pipe(takeUntilDestroyed(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;
// Determine render mode based on state
this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount);
this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar(
this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks,
);
// Update all computed properties when data changes
this.updateComputedProperties();
this.cdr.markForCheck();
});
}
private determineRenderMode(
summary: OrganizationReportSummary,
taskMetrics: TaskMetrics,
atRiskPasswordsCount: number,
): RenderMode {
// State 1: No critical apps setup
if (summary.totalCriticalApplicationCount === 0) {
return RenderMode.noCriticalApps;
}
// State 2: Critical apps with at-risk passwords but no tasks assigned yet
// OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks)
if (
summary.totalCriticalApplicationCount > 0 &&
(taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks)
) {
return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks;
}
// State 3: Critical apps with at-risk apps and tasks (progress tracking)
if (
summary.totalCriticalApplicationCount > 0 &&
taskMetrics.totalTasks > 0 &&
atRiskPasswordsCount <= taskMetrics.totalTasks
) {
return RenderMode.criticalAppsWithAtRiskAppsAndTasks;
}
// Default to no critical apps
return RenderMode.noCriticalApps;
}
/**
* Updates all computed properties based on current state.
* Called whenever data changes to avoid recalculation on every change detection cycle.
*/
private updateComputedProperties(): void {
// Calculate completion percentage
this.completedPercent =
this.totalTasks === 0 ? 0 : Math.round((this.completedTasks / this.totalTasks) * 100);
// Calculate completed tasks count based on render mode
switch (this.renderMode) {
case RenderMode.noCriticalApps:
case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks:
this.completedTasksCount = 0;
break;
case RenderMode.criticalAppsWithAtRiskAppsAndTasks:
this.completedTasksCount = this.completedTasks;
break;
default:
this.completedTasksCount = 0;
}
// Calculate total tasks count based on render mode
switch (this.renderMode) {
case RenderMode.noCriticalApps:
this.totalTasksCount = 0;
break;
case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks:
this.totalTasksCount = this.atRiskAppsCount;
break;
case RenderMode.criticalAppsWithAtRiskAppsAndTasks:
this.totalTasksCount = this.totalTasks;
break;
default:
this.totalTasksCount = 0;
}
// Calculate flags and counts
this.canAssignTasks = this.atRiskPasswordsCount > this.totalTasks;
this.hasExistingTasks = this.totalTasks > 0;
this.newAtRiskPasswordsCount =
this.atRiskPasswordsCount > this.totalTasks ? this.atRiskPasswordsCount - this.totalTasks : 0;
}
get renderModes() {
return RenderMode;
} }
async assignTasks() { async assignTasks() {
await this.accessIntelligenceSecurityTasksService.assignTasks( try {
this.organizationId, await this.securityTasksService.requestPasswordChangeForCriticalApplications(
this.allApplicationsDetails.filter((app) => app.isMarkedAsCritical), this.organizationId(),
); this._atRiskCipherIds(),
);
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"),
});
}
} }
} }

View File

@@ -4,8 +4,10 @@
<ul <ul
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" 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 }"> <li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': extendPasswordChangeWidget }">
<dirt-password-change-metric></dirt-password-change-metric> <dirt-password-change-metric
[organizationId]="this.organizationId()"
></dirt-password-change-metric>
</li> </li>
<li class="tw-col-span-1"> <li class="tw-col-span-1">

View File

@@ -1,7 +1,7 @@
import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { Component, DestroyRef, inject, input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { import {
AllActivitiesService, AllActivitiesService,
@@ -10,10 +10,6 @@ import {
RiskInsightsDataService, RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -37,13 +33,15 @@ import { NewApplicationsDialogComponent } from "./application-review-dialog/new-
templateUrl: "./all-activity.component.html", templateUrl: "./all-activity.component.html",
}) })
export class AllActivityComponent implements OnInit { export class AllActivityComponent implements OnInit {
organization: Organization | null = null; // Prefer component input since route param controls UI state
readonly organizationId = input.required<OrganizationId>();
totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsAtRiskMemberCount = 0;
totalCriticalAppsCount = 0; totalCriticalAppsCount = 0;
totalCriticalAppsAtRiskCount = 0; totalCriticalAppsAtRiskCount = 0;
newApplicationsCount = 0; newApplicationsCount = 0;
newApplications: ApplicationHealthReportDetail[] = []; newApplications: ApplicationHealthReportDetail[] = [];
passwordChangeMetricHasProgressBar = false; extendPasswordChangeWidget = false;
allAppsHaveReviewDate = false; allAppsHaveReviewDate = false;
isAllCaughtUp = false; isAllCaughtUp = false;
hasLoadedApplicationData = false; hasLoadedApplicationData = false;
@@ -53,7 +51,6 @@ export class AllActivityComponent implements OnInit {
protected ReportStatusEnum = ReportStatus; protected ReportStatusEnum = ReportStatus;
constructor( constructor(
private accountService: AccountService,
protected activatedRoute: ActivatedRoute, protected activatedRoute: ActivatedRoute,
protected allActivitiesService: AllActivitiesService, protected allActivitiesService: AllActivitiesService,
protected dataService: RiskInsightsDataService, protected dataService: RiskInsightsDataService,
@@ -62,53 +59,43 @@ export class AllActivityComponent implements OnInit {
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); this.allActivitiesService.reportSummary$
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((summary) => {
this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount;
this.totalCriticalAppsCount = summary.totalCriticalApplicationCount;
this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount;
});
if (organizationId) { this.dataService.newApplications$
this.organization = .pipe(takeUntilDestroyed(this.destroyRef))
(await firstValueFrom( .subscribe((newApps) => {
this.organizationService.organizations$(userId).pipe(getById(organizationId)), this.newApplications = newApps;
)) ?? null; this.newApplicationsCount = newApps.length;
this.updateIsAllCaughtUp();
});
this.allActivitiesService.reportSummary$ this.allActivitiesService.extendPasswordChangeWidget$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((summary) => { .subscribe((hasProgressBar) => {
this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; this.extendPasswordChangeWidget = hasProgressBar;
this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; });
this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount;
});
this.dataService.newApplications$ this.dataService.enrichedReportData$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((newApps) => { .subscribe((enrichedData) => {
this.newApplications = newApps; if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) {
this.newApplicationsCount = newApps.length; this.hasLoadedApplicationData = true;
this.updateIsAllCaughtUp(); // Check if all apps have a review date (not null and not undefined)
}); this.allAppsHaveReviewDate = enrichedData.applicationData.every(
(app) => app.reviewedDate !== null && app.reviewedDate !== undefined,
this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ );
.pipe(takeUntilDestroyed(this.destroyRef)) } else {
.subscribe((hasProgressBar) => { this.hasLoadedApplicationData = enrichedData !== null;
this.passwordChangeMetricHasProgressBar = hasProgressBar; this.allAppsHaveReviewDate = false;
}); }
this.updateIsAllCaughtUp();
this.dataService.enrichedReportData$ });
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((enrichedData) => {
if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) {
this.hasLoadedApplicationData = true;
// Check if all apps have a review date (not null and not undefined)
this.allAppsHaveReviewDate = enrichedData.applicationData.every(
(app) => app.reviewedDate !== null && app.reviewedDate !== undefined,
);
} else {
this.hasLoadedApplicationData = enrichedData !== null;
this.allAppsHaveReviewDate = false;
}
this.updateIsAllCaughtUp();
});
}
} }
/** /**

View File

@@ -10,9 +10,6 @@ import {
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { DarkImageSourceDirective } from "@bitwarden/vault"; import { DarkImageSourceDirective } from "@bitwarden/vault";
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
/** /**
* Embedded component for displaying task assignment UI. * Embedded component for displaying task assignment UI.
* Not a dialog - intended to be embedded within a parent dialog. * Not a dialog - intended to be embedded within a parent dialog.
@@ -36,7 +33,6 @@ import { AccessIntelligenceSecurityTasksService } from "../../shared/security-ta
DarkImageSourceDirective, DarkImageSourceDirective,
CalloutComponent, CalloutComponent,
], ],
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
}) })
export class AssignTasksViewComponent { export class AssignTasksViewComponent {
readonly criticalApplicationsCount = input.required<number>(); readonly criticalApplicationsCount = input.required<number>();

View File

@@ -8,12 +8,10 @@ import {
signal, signal,
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { from, switchMap } from "rxjs"; import { from, switchMap, take } from "rxjs";
import { import {
ApplicationHealthReportDetail, ApplicationHealthReportDetail,
ApplicationHealthReportDetailEnriched,
OrganizationReportApplication,
RiskInsightsDataService, RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
@@ -209,40 +207,16 @@ export class NewApplicationsDialogComponent {
} }
this.saving.set(true); this.saving.set(true);
// Create updated organization report application types with new review date
// and critical marking based on selected applications
const newReviewDate = new Date();
const updatedApplications: OrganizationReportApplication[] =
this.dialogParams.newApplications.map((app) => ({
applicationName: app.applicationName,
isCritical: this.selectedApplications().has(app.applicationName),
reviewedDate: newReviewDate,
}));
// Save the application review dates and critical markings // Save the application review dates and critical markings
this.dataService this.dataService.criticalApplicationAtRiskCipherIds$
.saveApplicationReviewStatus(updatedApplications)
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
switchMap((updatedState) => { take(1), // Handle unsubscribe for one off operation
// After initial save is complete, created the assigned tasks switchMap((criticalApplicationAtRiskCipherIds) => {
// for at risk passwords
const updatedStateApplicationData = updatedState?.data?.applicationData || [];
// Manual enrich for type matching
// TODO Consolidate in model updates
const manualEnrichedApplications =
updatedState?.data?.reportData.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: updatedStateApplicationData.some(
(a) => a.applicationName == application.applicationName && a.isCritical,
),
}),
) || [];
return from( return from(
this.accessIntelligenceSecurityTasksService.assignTasks( this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications(
this.dialogParams.organizationId, this.dialogParams.organizationId,
manualEnrichedApplications, criticalApplicationAtRiskCipherIds,
), ),
); );
}), }),

View File

@@ -4,7 +4,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { debounceTime, EMPTY, map, switchMap } from "rxjs"; import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs";
import { Security } from "@bitwarden/assets/svg"; import { Security } from "@bitwarden/assets/svg";
import { import {
@@ -23,7 +23,6 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
import { RiskInsightsTabType } from "../models/risk-insights.models"; import { RiskInsightsTabType } from "../models/risk-insights.models";
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
@@ -42,7 +41,6 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks
SharedModule, SharedModule,
AppTableRowScrollableComponent, AppTableRowScrollableComponent,
], ],
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
}) })
export class CriticalApplicationsComponent implements OnInit { export class CriticalApplicationsComponent implements OnInit {
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -58,13 +56,13 @@ export class CriticalApplicationsComponent implements OnInit {
constructor( constructor(
protected activatedRoute: ActivatedRoute, protected activatedRoute: ActivatedRoute,
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService, protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService, protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected i18nService: I18nService, protected i18nService: I18nService,
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, protected reportService: RiskInsightsReportService,
protected router: Router,
private securityTasksService: AccessIntelligenceSecurityTasksService,
protected toastService: ToastService,
) { ) {
this.searchControl.valueChanges this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed()) .pipe(debounceTime(200), takeUntilDestroyed())
@@ -131,10 +129,35 @@ export class CriticalApplicationsComponent implements OnInit {
}; };
async requestPasswordChange() { async requestPasswordChange() {
await this.accessIntelligenceSecurityTasksService.assignTasks( this.dataService.criticalApplicationAtRiskCipherIds$
this.organizationId, .pipe(
this.dataSource.data, takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
); take(1), // Handle unsubscribe for one off operation
switchMap((cipherIds) => {
return from(
this.securityTasksService.requestPasswordChangeForCriticalApplications(
this.organizationId,
cipherIds,
),
);
}),
)
.subscribe({
next: () => {
this.toastService.showToast({
message: this.i18nService.t("notifiedMembers"),
variant: "success",
title: this.i18nService.t("success"),
});
},
error: () => {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
},
});
} }
showAppAtRiskMembers = async (applicationName: string) => { showAppAtRiskMembers = async (applicationName: string) => {

View File

@@ -1,7 +0,0 @@
export const RenderMode = {
noCriticalApps: "noCriticalApps",
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
} as const;
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];

View File

@@ -87,7 +87,7 @@
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)"> <bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) { @if (isRiskInsightsActivityTabFeatureEnabled) {
<bit-tab label="{{ 'activity' | i18n }}"> <bit-tab label="{{ 'activity' | i18n }}">
<dirt-all-activity></dirt-all-activity> <dirt-all-activity [organizationId]="this.organizationId"></dirt-all-activity>
</bit-tab> </bit-tab>
} }
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}"> <bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">

View File

@@ -78,7 +78,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
appsCount: number = 0; appsCount: number = 0;
private organizationId: OrganizationId = "" as OrganizationId; protected organizationId: OrganizationId = "" as OrganizationId;
dataLastUpdated: Date | null = null; dataLastUpdated: Date | null = null;

View File

@@ -1,14 +1,9 @@
import { TestBed } from "@angular/core/testing"; import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
AllActivitiesService, import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
ApplicationHealthReportDetailEnriched,
} 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 { SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
@@ -16,18 +11,14 @@ import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service
describe("AccessIntelligenceSecurityTasksService", () => { describe("AccessIntelligenceSecurityTasksService", () => {
let service: AccessIntelligenceSecurityTasksService; let service: AccessIntelligenceSecurityTasksService;
const defaultAdminTaskServiceSpy = mock<DefaultAdminTaskService>(); const defaultAdminTaskServiceMock = mock<DefaultAdminTaskService>();
const allActivitiesServiceSpy = mock<AllActivitiesService>(); const securityTasksApiServiceMock = mock<SecurityTasksApiService>();
const toastServiceSpy = mock<ToastService>();
const i18nServiceSpy = mock<I18nService>();
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({}); TestBed.configureTestingModule({});
service = new AccessIntelligenceSecurityTasksService( service = new AccessIntelligenceSecurityTasksService(
allActivitiesServiceSpy, defaultAdminTaskServiceMock,
defaultAdminTaskServiceSpy, securityTasksApiServiceMock,
toastServiceSpy,
i18nServiceSpy,
); );
}); });
@@ -36,104 +27,48 @@ describe("AccessIntelligenceSecurityTasksService", () => {
}); });
describe("assignTasks", () => { describe("assignTasks", () => {
it("should call requestPasswordChange and setTaskCreatedCount", async () => { it("should call requestPasswordChangeForCriticalApplications and setTaskCreatedCount", async () => {
// Set up test data
const organizationId = "org-1" as OrganizationId; const organizationId = "org-1" as OrganizationId;
const apps = [ const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId];
{ const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications");
isMarkedAsCritical: true,
atRiskPasswordCount: 1, // Call the method
atRiskCipherIds: ["cid1"], await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds);
} as ApplicationHealthReportDetailEnriched,
]; // Verify that the method was called with correct parameters
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds);
await service.assignTasks(organizationId, apps);
expect(spy).toHaveBeenCalledWith(organizationId, apps);
expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2);
}); });
}); });
describe("requestPasswordChange", () => { describe("requestPasswordChangeForCriticalApplications", () => {
it("should create tasks for distinct cipher ids and show success toast", async () => { it("should create tasks for distinct cipher ids and show success toast", async () => {
// Set up test data
const organizationId = "org-2" as OrganizationId; const organizationId = "org-2" as OrganizationId;
const apps = [ const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId];
{ defaultAdminTaskServiceMock.bulkCreateTasks.mockResolvedValue(undefined);
isMarkedAsCritical: true, const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications");
atRiskPasswordCount: 2,
atRiskCipherIds: ["cid1", "cid2"],
} as ApplicationHealthReportDetailEnriched,
{
isMarkedAsCritical: true,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid2"],
} as ApplicationHealthReportDetailEnriched,
];
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
i18nServiceSpy.t.mockImplementation((key) => key);
const result = await service.requestPasswordChange(organizationId, apps); // Call the method
await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ // Verify that bulkCreateTasks was called with distinct cipher ids
expect(defaultAdminTaskServiceMock.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [
{ cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential }, { cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential },
{ cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential }, { cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential },
]); ]);
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ // Verify that the method was called with correct parameters
message: "notifiedMembers", expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds);
variant: "success",
title: "success",
});
expect(result).toBe(2);
}); });
it("should show error toast and return 0 if bulkCreateTasks throws", async () => { it("should handle error if defaultAdminTaskService errors", async () => {
const organizationId = "org-3" as OrganizationId; const organizationId = "org-3" as OrganizationId;
const apps = [ const mockCipherIds = ["cid3" as CipherId];
{ defaultAdminTaskServiceMock.bulkCreateTasks.mockRejectedValue(new Error("API fail error"));
isMarkedAsCritical: true,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid3"],
} as ApplicationHealthReportDetailEnriched,
];
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
i18nServiceSpy.t.mockImplementation((key) => key);
const result = await service.requestPasswordChange(organizationId, apps); await expect(
service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds),
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ ).rejects.toThrow("API fail error");
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 = [
{
isMarkedAsCritical: true,
atRiskPasswordCount: 0,
atRiskCipherIds: ["cid4"],
} as ApplicationHealthReportDetailEnriched,
];
const result = await service.requestPasswordChange(organizationId, apps);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0);
});
it("should not create any tasks for non-critical apps", async () => {
const organizationId = "org-5" as OrganizationId;
const apps = [
{
isMarkedAsCritical: false,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cid5", "cid6"],
} as ApplicationHealthReportDetailEnriched,
];
const result = await service.requestPasswordChange(organizationId, apps);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0);
}); });
}); });
}); });

View File

@@ -1,64 +1,63 @@
import { Injectable } from "@angular/core"; import { BehaviorSubject } from "rxjs";
import { import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
AllActivitiesService,
ApplicationHealthReportDetailEnriched,
} 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 { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
@Injectable() /**
* Service for managing security tasks related to Access Intelligence features
*/
export class AccessIntelligenceSecurityTasksService { export class AccessIntelligenceSecurityTasksService {
private _tasksSubject$ = new BehaviorSubject<SecurityTask[]>([]);
tasks$ = this._tasksSubject$.asObservable();
constructor( constructor(
private allActivitiesService: AllActivitiesService,
private adminTaskService: DefaultAdminTaskService, private adminTaskService: DefaultAdminTaskService,
private toastService: ToastService, private securityTasksApiService: SecurityTasksApiService,
private i18nService: I18nService,
) {} ) {}
async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) {
const taskCount = await this.requestPasswordChange(organizationId, apps); /**
this.allActivitiesService.setTaskCreatedCount(taskCount); * Gets security task metrics for the given organization
*
* @param organizationId The organization ID
* @returns Metrics about security tasks such as a count of completed and total tasks
*/
getTaskMetrics(organizationId: OrganizationId) {
return this.securityTasksApiService.getTaskMetrics(organizationId);
} }
// TODO: this method is shared between here and critical-applications.component.ts /**
async requestPasswordChange( * Loads security tasks for the given organization and updates the internal tasks subject
*
* @param organizationId The organization ID
*/
async loadTasks(organizationId: OrganizationId): Promise<void> {
// Loads the tasks to update the service
const tasks = await this.securityTasksApiService.getAllTasks(organizationId);
this._tasksSubject$.next(tasks);
}
/**
* Bulk assigns password change tasks for critical applications with at-risk passwords
*
* @param organizationId The organization ID
* @param criticalApplicationIds IDs of critical applications with at-risk passwords
*/
async requestPasswordChangeForCriticalApplications(
organizationId: OrganizationId, organizationId: OrganizationId,
apps: ApplicationHealthReportDetailEnriched[], criticalApplicationIds: CipherId[],
): Promise<number> { ) {
// Only create tasks for CRITICAL applications with at-risk passwords const distinctCipherIds = Array.from(new Set(criticalApplicationIds));
const cipherIds = apps
.filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0)
.flatMap((app) => app.atRiskCipherIds);
const distinctCipherIds = Array.from(new Set(cipherIds));
const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({
cipherId: cipherId as CipherId, cipherId,
type: SecurityTaskType.UpdateAtRiskCredential, type: SecurityTaskType.UpdateAtRiskCredential,
})); }));
try { await this.adminTaskService.bulkCreateTasks(organizationId, tasks);
await this.adminTaskService.bulkCreateTasks(organizationId, tasks); // Reload tasks after creation
this.toastService.showToast({ await this.loadTasks(organizationId);
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;
} }
} }