From da9e12dae69c53da61611532ede2a5feb20df015 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:03:15 -0800 Subject: [PATCH] [PM-15498] - add risk insight data service (#12361) * Adding more test cases * Removing unnecessary file * Test cases update * Adding the fixme for strict types * moving the fixme * add risk insight data service and wire up components to it * hook up all applications to risk insights report service. add loading state * link up remaining props to risk insight report * wire up children to risk insight data service * add missing copy. remove loading state from risk insights * fix types * fix DI issue * remove @Injectable from RiskInsightsDataService --------- Co-authored-by: Tom Co-authored-by: Tom <144813356+ttalty@users.noreply.github.com> --- .../layouts/organization-layout.component.ts | 1 - apps/web/src/locales/en/messages.json | 3 + .../risk-insights/models/password-health.ts | 2 +- .../reports/risk-insights/services/index.ts | 1 + .../services/risk-insights-data.service.ts | 60 ++++++++++ .../services/risk-insights-report.service.ts | 1 + .../access-intelligence.module.ts | 5 + .../all-applications.component.html | 49 ++++---- .../all-applications.component.ts | 101 ++++++++++------- .../risk-insights-loading.component.html | 8 ++ .../risk-insights-loading.component.ts | 14 +++ .../risk-insights.component.html | 105 ++++++++++-------- .../risk-insights.component.ts | 105 ++++++++++++------ 13 files changed, 303 insertions(+), 152 deletions(-) create mode 100644 bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-data.service.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html create mode 100644 bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.ts diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 8c4f5ce8c4..0b024817ed 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -57,7 +57,6 @@ export class OrganizationLayoutComponent implements OnInit { showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - isAccessIntelligenceFeatureEnabled = false; enterpriseOrganization$: Observable; constructor( diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 36143682fa..abd5779339 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3882,6 +3882,9 @@ "updateBrowser": { "message": "Update browser" }, + "generatingRiskInsights": { + "message": "Generating your risk insights..." + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts index 427cb06d9e..b8d5852088 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts @@ -25,7 +25,7 @@ export type ApplicationHealthReportDetail = { passwordCount: number; atRiskPasswordCount: number; memberCount: number; - + atRiskMemberCount: number; memberDetails: MemberDetailsFlat[]; atRiskMemberDetails: MemberDetailsFlat[]; }; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts index e930c7666e..a8e62437b9 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts @@ -1,3 +1,4 @@ export * from "./member-cipher-details-api.service"; export * from "./password-health.service"; export * from "./risk-insights-report.service"; +export * from "./risk-insights-data.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-data.service.ts new file mode 100644 index 0000000000..42bab69fca --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-data.service.ts @@ -0,0 +1,60 @@ +import { BehaviorSubject } from "rxjs"; +import { finalize } from "rxjs/operators"; + +import { ApplicationHealthReportDetail } from "../models/password-health"; + +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +export class RiskInsightsDataService { + private applicationsSubject = new BehaviorSubject(null); + + applications$ = this.applicationsSubject.asObservable(); + + private isLoadingSubject = new BehaviorSubject(false); + isLoading$ = this.isLoadingSubject.asObservable(); + + private isRefreshingSubject = new BehaviorSubject(false); + isRefreshing$ = this.isRefreshingSubject.asObservable(); + + private errorSubject = new BehaviorSubject(null); + error$ = this.errorSubject.asObservable(); + + private dataLastUpdatedSubject = new BehaviorSubject(null); + dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); + + constructor(private reportService: RiskInsightsReportService) {} + + /** + * Fetches the applications report and updates the applicationsSubject. + * @param organizationId The ID of the organization. + */ + fetchApplicationsReport(organizationId: string, isRefresh?: boolean): void { + if (isRefresh) { + this.isRefreshingSubject.next(true); + } else { + this.isLoadingSubject.next(true); + } + this.reportService + .generateApplicationsReport$(organizationId) + .pipe( + finalize(() => { + this.isLoadingSubject.next(false); + this.isRefreshingSubject.next(false); + this.dataLastUpdatedSubject.next(new Date()); + }), + ) + .subscribe({ + next: (reports: ApplicationHealthReportDetail[]) => { + this.applicationsSubject.next(reports); + this.errorSubject.next(null); + }, + error: () => { + this.applicationsSubject.next([]); + }, + }); + } + + refreshApplicationsReport(organizationId: string): void { + this.fetchApplicationsReport(organizationId, true); + } +} diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts index 45746cd2f5..c5e9e3625d 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts @@ -287,6 +287,7 @@ export class RiskInsightsReportService { : newUriDetail.cipherMembers, atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [], atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0, + atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0, } as ApplicationHealthReportDetail; if (isAtRisk) { diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts index 87b75dee70..2db7af4bb4 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { MemberCipherDetailsApiService, + RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/tools/reports/risk-insights/services"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -28,6 +29,10 @@ import { RiskInsightsComponent } from "./risk-insights.component"; MemberCipherDetailsApiService, ], }, + { + provide: RiskInsightsDataService, + deps: [RiskInsightsReportService], + }, ], }) export class AccessIntelligenceModule {} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html index 4ed31adea7..ea1a4f9db3 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html @@ -1,16 +1,11 @@ -
- - {{ "loading" | i18n }} +
+
-
+

- {{ "noAppsInOrgTitle" | i18n: organization.name }} + {{ "noAppsInOrgTitle" | i18n: organization?.name }}

@@ -28,21 +23,21 @@
-
+

{{ "allApplications" | i18n }}

@@ -57,7 +52,7 @@ type="button" buttonType="secondary" bitButton - *ngIf="isCritialAppsFeatureEnabled" + *ngIf="isCriticalAppsFeatureEnabled" [disabled]="!selectedIds.size" [loading]="markingAsCritical" (click)="markAppsAsCritical()" @@ -69,17 +64,17 @@ - - {{ "application" | i18n }} - {{ "atRiskPasswords" | i18n }} - {{ "totalPasswords" | i18n }} - {{ "atRiskMembers" | i18n }} - {{ "totalMembers" | i18n }} + + {{ "application" | i18n }} + {{ "atRiskPasswords" | i18n }} + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }} + {{ "totalMembers" | i18n }} - + - {{ r.name }} + {{ r.applicationName }} - {{ r.atRiskPasswords }} + {{ r.atRiskPasswordCount }} - {{ r.totalPasswords }} + {{ r.passwordCount }} - {{ r.atRiskMembers }} + {{ r.atRiskMemberCount }} - {{ r.totalMembers }} + {{ r.memberCount }} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts index 6ee2ecf169..f4d3656071 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts @@ -1,20 +1,23 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, DestroyRef, inject, OnInit } from "@angular/core"; +import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { debounceTime, firstValueFrom, map } from "rxjs"; +import { debounceTime, map, Observable, of, Subscription } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { + RiskInsightsDataService, + RiskInsightsReportService, +} from "@bitwarden/bit-common/tools/reports/risk-insights"; +import { + ApplicationHealthReportDetail, + ApplicationHealthReportSummary, +} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { Icons, NoItemsModule, @@ -27,60 +30,76 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { applicationTableMockData } from "./application-table.mock"; +import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; @Component({ standalone: true, selector: "tools-all-applications", templateUrl: "./all-applications.component.html", - imports: [HeaderModule, CardComponent, SearchModule, PipesModule, NoItemsModule, SharedModule], + imports: [ + ApplicationsLoadingComponent, + HeaderModule, + CardComponent, + SearchModule, + PipesModule, + NoItemsModule, + SharedModule, + ], }) -export class AllApplicationsComponent implements OnInit { - protected dataSource = new TableDataSource(); +export class AllApplicationsComponent implements OnInit, OnDestroy { + protected dataSource = new TableDataSource(); protected selectedIds: Set = new Set(); protected searchControl = new FormControl("", { nonNullable: true }); - private destroyRef = inject(DestroyRef); - protected loading = false; - protected organization: Organization; + protected loading = true; + protected organization = {} as Organization; noItemsIcon = Icons.Security; protected markingAsCritical = false; - isCritialAppsFeatureEnabled = false; + protected applicationSummary = {} as ApplicationHealthReportSummary; + private subscription = new Subscription(); - // MOCK DATA - protected mockData = applicationTableMockData; - protected mockAtRiskMembersCount = 0; - protected mockAtRiskAppsCount = 0; - protected mockTotalMembersCount = 0; - protected mockTotalAppsCount = 0; + destroyRef = inject(DestroyRef); + isLoading$: Observable = of(false); + isCriticalAppsFeatureEnabled = false; async ngOnInit() { - this.activatedRoute.paramMap - .pipe( - takeUntilDestroyed(this.destroyRef), - map(async (params) => { - const organizationId = params.get("organizationId"); - this.organization = await firstValueFrom(this.organizationService.get$(organizationId)); - // TODO: use organizationId to fetch data - }), - ) - .subscribe(); - - this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag( + this.isCriticalAppsFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.CriticalApps, ); + + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + + if (organizationId) { + this.organization = await this.organizationService.get(organizationId); + this.subscription = this.dataService.applications$ + .pipe( + map((applications) => { + if (applications) { + this.dataSource.data = applications; + this.applicationSummary = + this.reportService.generateApplicationsSummary(applications); + } + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + this.isLoading$ = this.dataService.isLoading$; + } + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); } constructor( protected cipherService: CipherService, - protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, - protected organizationService: OrganizationService, protected configService: ConfigService, + protected dataService: RiskInsightsDataService, + protected organizationService: OrganizationService, + protected reportService: RiskInsightsReportService, ) { - this.dataSource.data = applicationTableMockData; this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); @@ -90,7 +109,7 @@ export class AllApplicationsComponent implements OnInit { // TODO: implement this.toastService.showToast({ variant: "warning", - title: null, + title: "", message: "Not yet implemented", }); }; @@ -103,7 +122,7 @@ export class AllApplicationsComponent implements OnInit { this.selectedIds.clear(); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("appsMarkedAsCritical"), }); resolve(true); @@ -112,8 +131,8 @@ export class AllApplicationsComponent implements OnInit { }); }; - trackByFunction(_: number, item: CipherView) { - return item.id; + trackByFunction(_: number, item: ApplicationHealthReportDetail) { + return item.applicationName; } onCheckboxChange(id: number, event: Event) { diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html new file mode 100644 index 0000000000..d6f945bfb9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html @@ -0,0 +1,8 @@ +
+ +

{{ "generatingRiskInsights" | i18n }}

+
diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.ts new file mode 100644 index 0000000000..1cafa62c60 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.ts @@ -0,0 +1,14 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; + +@Component({ + selector: "tools-risk-insights-loading", + standalone: true, + imports: [CommonModule, JslibModule], + templateUrl: "./risk-insights-loading.component.html", +}) +export class ApplicationsLoadingComponent { + constructor() {} +} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html index 6df47e3c46..e0618c525a 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html @@ -1,49 +1,58 @@ -
{{ "accessIntelligence" | i18n }}
-

{{ "riskInsights" | i18n }}

-
- {{ "reviewAtRiskPasswords" | i18n }} -  {{ "learnMore" | i18n }} -
-
- - {{ - "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") - }} - +
{{ "accessIntelligence" | i18n }}
+

{{ "riskInsights" | i18n }}

+
+
- {{ "refresh" | i18n }} - -
- - - - - - - - {{ "criticalApplicationsWithCount" | i18n: criticalApps.length }} - - - - - - - - - - - - - - + + {{ + "dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") + }} + + + {{ "refresh" | i18n }} + + + + + +
+ + + + + + + + {{ "criticalApplicationsWithCount" | i18n: criticalAppsCount }} + + + + + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts index 5ea39bd051..1a90e18f0d 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts @@ -1,11 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; +import { Observable, EMPTY } from "rxjs"; +import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/tools/reports/risk-insights"; +import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; @@ -43,45 +45,80 @@ export enum RiskInsightsTabType { ], }) export class RiskInsightsComponent implements OnInit { - tabIndex: RiskInsightsTabType; - dataLastUpdated = new Date(); - isCritialAppsFeatureEnabled = false; + tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; - apps: any[] = []; - criticalApps: any[] = []; - notifiedMembers: any[] = []; + dataLastUpdated: Date = new Date(); - async refreshData() { - // TODO: Implement - return new Promise((resolve) => - setTimeout(() => { - this.dataLastUpdated = new Date(); - resolve(true); - }, 1000), - ); + isCriticalAppsFeatureEnabled: boolean = false; + + appsCount: number = 0; + criticalAppsCount: number = 0; + notifiedMembersCount: number = 0; + + private organizationId: string | null = null; + private destroyRef = inject(DestroyRef); + isLoading$: Observable = new Observable(); + isRefreshing$: Observable = new Observable(); + dataLastUpdated$: Observable = new Observable(); + refetching: boolean = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private configService: ConfigService, + private dataService: RiskInsightsDataService, + ) { + this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { + this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; + }); } - onTabChange = async (newIndex: number) => { + async ngOnInit() { + this.isCriticalAppsFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CriticalApps, + ); + + this.route.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap((orgId: string | null) => { + if (orgId) { + this.organizationId = orgId; + this.dataService.fetchApplicationsReport(orgId); + this.isLoading$ = this.dataService.isLoading$; + this.isRefreshing$ = this.dataService.isRefreshing$; + this.dataLastUpdated$ = this.dataService.dataLastUpdated$; + return this.dataService.applications$; + } else { + return EMPTY; + } + }), + ) + .subscribe({ + next: (applications: ApplicationHealthReportDetail[] | null) => { + if (applications) { + this.appsCount = applications.length; + } + }, + }); + } + + /** + * Refreshes the data by re-fetching the applications report. + * This will automatically notify child components subscribed to the RiskInsightsDataService observables. + */ + refreshData(): void { + if (this.organizationId) { + this.dataService.refreshApplicationsReport(this.organizationId); + } + } + + async onTabChange(newIndex: number): Promise { await this.router.navigate([], { relativeTo: this.route, queryParams: { tabIndex: newIndex }, queryParamsHandling: "merge", }); - }; - - async ngOnInit() { - this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag( - FeatureFlag.CriticalApps, - ); - } - - constructor( - protected route: ActivatedRoute, - private router: Router, - private configService: ConfigService, - ) { - route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { - this.tabIndex = !isNaN(tabIndex) ? tabIndex : RiskInsightsTabType.AllApps; - }); } }