1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 06:23:38 +00:00

wire up children to risk insight data service

This commit is contained in:
jaasen-livefront
2024-12-12 13:25:56 -08:00
parent 7d70c18331
commit 7f1cfef1b0
5 changed files with 102 additions and 80 deletions

View File

@@ -1,53 +1,62 @@
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { shareReplay } from "rxjs/operators";
import { BehaviorSubject } from "rxjs";
import { finalize } from "rxjs/operators";
import { ApplicationHealthReportDetail } from "../models/password-health";
import { RiskInsightsReportService } from "./risk-insights-report.service";
/**
* Singleton service to manage the report details for the Risk Insights reports.
*/
@Injectable({
providedIn: "root",
})
@Injectable()
export class RiskInsightsDataService {
// Map to store observables per organizationId
private applicationsReportMap = new Map<string, Observable<ApplicationHealthReportDetail[]>>();
private applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(null);
applications$ = this.applicationsSubject.asObservable();
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
private isRefreshingSubject = new BehaviorSubject<boolean>(false);
isRefreshing$ = this.isRefreshingSubject.asObservable();
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable();
constructor(private reportService: RiskInsightsReportService) {}
/**
* Returns an observable for the applications report of a given organizationId.
* Utilizes shareReplay to ensure that the data is fetched only once
* and shared among multiple subscribers.
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
* @returns Observable of ApplicationHealthReportDetail[].
*/
getApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
// If the observable for this organizationId already exists, return it
if (this.applicationsReportMap.has(organizationId)) {
return this.applicationsReportMap.get(organizationId)!;
fetchApplicationsReport(organizationId: string, isRefresh?: boolean): void {
if (isRefresh) {
this.isRefreshingSubject.next(true);
} else {
this.isLoadingSubject.next(true);
}
const applicationsReport$ = this.reportService
this.reportService
.generateApplicationsReport$(organizationId)
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
// Store the observable in the map for future subscribers
this.applicationsReportMap.set(organizationId, applicationsReport$);
return applicationsReport$;
.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([]);
},
});
}
/**
* Clears the cached observable for a specific organizationId.
* @param organizationId The ID of the organization.
*/
clearApplicationsReportCache(organizationId: string): void {
if (this.applicationsReportMap.has(organizationId)) {
this.applicationsReportMap.delete(organizationId);
}
refreshApplicationsReport(organizationId: string): void {
this.fetchApplicationsReport(organizationId, true);
}
}

View File

@@ -1,11 +1,11 @@
<div *ngIf="loading">
<div *ngIf="isLoading$ | async">
<tools-risk-insights-loading></tools-risk-insights-loading>
</div>
<div class="tw-mt-4" *ngIf="!loading && !dataSource.data.length">
<div class="tw-mt-4" *ngIf="!(isLoading$ | async) && !dataSource.data.length">
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
<ng-container slot="title">
<h2 class="tw-font-semibold mt-4">
{{ "noAppsInOrgTitle" | i18n: organization.name }}
{{ "noAppsInOrgTitle" | i18n: organization?.name }}
</h2>
</ng-container>
<ng-container slot="description">
@@ -23,7 +23,7 @@
</ng-container>
</bit-no-items>
</div>
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!loading && dataSource.data.length">
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!(isLoading$ | async) && dataSource.data.length">
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-gap-6">
<tools-card

View File

@@ -1,11 +1,10 @@
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, map, switchMap } from "rxjs";
import { debounceTime, map, Observable, Subscription } from "rxjs";
import {
MemberCipherDetailsApiService,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/tools/reports/risk-insights";
@@ -13,6 +12,7 @@ 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";
@@ -45,19 +45,21 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"
NoItemsModule,
SharedModule,
],
providers: [MemberCipherDetailsApiService, RiskInsightsReportService],
providers: [RiskInsightsReportService],
})
export class AllApplicationsComponent implements OnInit {
export class AllApplicationsComponent implements OnInit, OnDestroy {
protected dataSource = new TableDataSource<ApplicationHealthReportDetail>();
protected selectedIds: Set<number> = new Set<number>();
protected searchControl = new FormControl("", { nonNullable: true });
private destroyRef = inject(DestroyRef);
protected loading = true;
protected organization: Organization;
noItemsIcon = Icons.Security;
protected markingAsCritical = false;
protected applicationSummary: ApplicationHealthReportSummary;
private subscription: Subscription;
destroyRef = inject(DestroyRef);
isLoading$: Observable<boolean>;
isCriticalAppsFeatureEnabled = false;
async ngOnInit() {
@@ -65,24 +67,28 @@ export class AllApplicationsComponent implements OnInit {
FeatureFlag.CriticalApps,
);
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((orgId) => {
return this.dataService.getApplicationsReport$(orgId);
}),
)
.subscribe({
next: (applications: ApplicationHealthReportDetail[]) => {
if (applications) {
this.dataSource.data = applications;
const summary = this.reportService.generateApplicationsSummary(applications);
this.applicationSummary = summary;
this.loading = false;
}
},
});
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(
@@ -92,6 +98,7 @@ export class AllApplicationsComponent implements OnInit {
protected toastService: ToastService,
protected configService: ConfigService,
protected dataService: RiskInsightsDataService,
protected organizationService: OrganizationService,
protected reportService: RiskInsightsReportService,
) {
this.searchControl.valueChanges

View File

@@ -1,4 +1,4 @@
<div *ngIf="loading">
<div *ngIf="isLoading$ | async">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
@@ -6,7 +6,7 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<ng-container *ngIf="!loading">
<ng-container *ngIf="!(isLoading$ | async)">
<div class="tw-mb-1 text-primary" bitTypography="body1">{{ "accessIntelligence" | i18n }}</div>
<h1 bitTypography="h1">{{ "riskInsights" | i18n }}</h1>
<div class="tw-text-muted tw-max-w-4xl tw-mb-2">
@@ -21,11 +21,11 @@
aria-hidden="true"
></i>
<span class="tw-mx-4">{{
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
"dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a")
}}</span>
<span class="tw-flex tw-justify-center tw-w-16">
<a
*ngIf="!refetching"
*ngIf="!(isRefreshing$ | async)"
bitButton
buttonType="unstyled"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
@@ -35,7 +35,7 @@
</a>
<span>
<i
*ngIf="refetching"
*ngIf="isRefreshing$ | async"
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true"
></i>

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { Observable } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -61,7 +62,9 @@ export class RiskInsightsComponent implements OnInit {
private organizationId: string;
private destroyRef = inject(DestroyRef);
loading = true;
isLoading$: Observable<boolean>;
isRefreshing$: Observable<boolean>;
dataLastUpdated$: Observable<Date>;
refetching = false;
constructor(
@@ -85,29 +88,32 @@ export class RiskInsightsComponent implements OnInit {
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((orgId) => {
this.organizationId = orgId;
return this.dataService.getApplicationsReport$(orgId);
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$;
}
}),
)
.subscribe({
next: (applications: ApplicationHealthReportDetail[]) => {
next: (applications: ApplicationHealthReportDetail[] | null) => {
if (applications) {
this.appsCount = applications.length;
this.loading = false;
this.refetching = false;
this.dataLastUpdated = new Date();
}
},
});
}
async refreshData() {
/**
* 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.refetching = true;
// Clear the cache to ensure fresh data is fetched
this.dataService.clearApplicationsReportCache(this.organizationId);
// Re-initialize to fetch data again
await this.ngOnInit();
this.dataService.refreshApplicationsReport(this.organizationId);
}
}