diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d56b293bfe6..56332e5ac50 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4446,6 +4446,30 @@ "generatingYourAccessIntelligence": { "message": "Generating your Access Intelligence..." }, + "fetchingMemberData": { + "message": "Fetching member data..." + }, + "analyzingPasswordHealth": { + "message": "Analyzing password health..." + }, + "calculatingRiskScores": { + "message": "Calculating risk scores..." + }, + "generatingReportData": { + "message": "Generating report data..." + }, + "savingReport": { + "message": "Saving report..." + }, + "compilingInsights": { + "message": "Compiling insights..." + }, + "loadingProgress": { + "message": "Loading progress" + }, + "thisMightTakeFewMinutes": { + "message": "This might take a few minutes." + }, "riskInsightsRunReport": { "message": "Run report" }, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index eecd8256c7f..ef666021fdc 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -107,6 +107,17 @@ export const ReportStatus = Object.freeze({ export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus]; +export const ReportProgress = Object.freeze({ + FetchingMembers: 1, + AnalyzingPasswords: 2, + CalculatingRisks: 3, + GeneratingReport: 4, + Saving: 5, + Complete: 6, +} as const); + +export type ReportProgress = (typeof ReportProgress)[keyof typeof ReportProgress]; + export interface RiskInsightsData { id: OrganizationReportId; creationDate: Date; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 387d594d4e3..a077c8345b5 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -56,6 +56,7 @@ import { OrganizationReportSummary, ReportStatus, ReportState, + ReportProgress, ApplicationHealthReportDetail, } from "../../models/report-models"; import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; @@ -128,6 +129,10 @@ export class RiskInsightsOrchestratorService { private _generateReportTriggerSubject = new BehaviorSubject(false); generatingReport$ = this._generateReportTriggerSubject.asObservable(); + // Report generation progress + private _reportProgressSubject = new BehaviorSubject(null); + reportProgress$ = this._reportProgressSubject.asObservable(); + // --------------------------- Critical Application data --------------------- criticalReportResults$: Observable = of(null); @@ -631,19 +636,33 @@ export class RiskInsightsOrchestratorService { organizationId: OrganizationId, userId: UserId, ): Observable { - // Generate the report + // Reset progress at the start + this._reportProgressSubject.next(null); + + this.logService.debug("[RiskInsightsOrchestratorService] Fetching member cipher details"); + this._reportProgressSubject.next(ReportProgress.FetchingMembers); + + // Generate the report - fetch member ciphers and org ciphers in parallel const memberCiphers$ = from( this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); - return forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe( - tap(() => { - this.logService.debug("[RiskInsightsOrchestratorService] Generating new report"); + // Start the generation pipeline + const reportGeneration$ = forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe( + switchMap(([ciphers, memberCiphers]) => { + this.logService.debug("[RiskInsightsOrchestratorService] Analyzing password health"); + this._reportProgressSubject.next(ReportProgress.AnalyzingPasswords); + return this._getCipherHealth(ciphers ?? [], memberCiphers); + }), + map((cipherHealthReports) => { + this.logService.debug("[RiskInsightsOrchestratorService] Calculating risk scores"); + this._reportProgressSubject.next(ReportProgress.CalculatingRisks); + return this.reportService.generateApplicationsReport(cipherHealthReports); + }), + tap(() => { + this.logService.debug("[RiskInsightsOrchestratorService] Generating report data"); + this._reportProgressSubject.next(ReportProgress.GeneratingReport); }), - switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers ?? [], memberCiphers)), - map((cipherHealthReports) => - this.reportService.generateApplicationsReport(cipherHealthReports), - ), withLatestFrom(this.rawReportData$), map(([report, previousReport]) => { // Update the application data @@ -680,6 +699,8 @@ export class RiskInsightsOrchestratorService { }; }), switchMap(({ report, summary, applications, metrics }) => { + this.logService.debug("[RiskInsightsOrchestratorService] Saving report"); + this._reportProgressSubject.next(ReportProgress.Saving); return this.reportService .saveRiskInsightsReport$(report, summary, applications, metrics, { organizationId, @@ -696,6 +717,10 @@ export class RiskInsightsOrchestratorService { ); }), // Update the running state + tap(() => { + this.logService.debug("[RiskInsightsOrchestratorService] Report generation complete"); + this._reportProgressSubject.next(ReportProgress.Complete); + }), map((mappedResult): ReportState => { const { id, report, summary, applications, contentEncryptionKey } = mappedResult; return { @@ -723,7 +748,9 @@ export class RiskInsightsOrchestratorService { error: null, data: null, }), - ); + ) as Observable; + + return reportGeneration$; } // Calculates the metrics for a report diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index cdfdbe740a0..ad49392df57 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -10,6 +10,7 @@ import { DrawerType, RiskInsightsEnrichedData, ReportStatus, + ReportProgress, ApplicationHealthReportDetail, OrganizationReportApplication, } from "../../models"; @@ -38,6 +39,7 @@ export class RiskInsightsDataService { readonly isGeneratingReport$: Observable = of(false); readonly criticalReportResults$: Observable = of(null); readonly hasCiphers$: Observable = of(null); + readonly reportProgress$: Observable = of(null); // New applications that need review (reviewedDate === null) readonly newApplications$: Observable = of([]); @@ -62,6 +64,7 @@ export class RiskInsightsDataService { this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$; this.newApplications$ = this.orchestrator.newApplications$; + this.reportProgress$ = this.orchestrator.reportProgress$; this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged()); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 10dfd002599..fad2afb6e38 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -1,15 +1,16 @@ @let status = dataService.reportStatus$ | async; @let hasCiphers = dataService.hasCiphers$ | async; + @let isGeneratingReport = dataService.isGeneratingReport$ | async; @if (status == ReportStatusEnum.Initializing || hasCiphers === null) { - - + + } @else { @if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {

{{ "accessIntelligence" | i18n }}

-
+
@if (!hasCiphers) { } @else { -
+

{{ "accessIntelligence" | i18n }}

@@ -78,28 +79,33 @@
-
- - @if (isRiskInsightsActivityTabFeatureEnabled) { - - + @if (status == ReportStatusEnum.Loading && isGeneratingReport) { + + + } @else { +
+ + @if (isRiskInsightsActivityTabFeatureEnabled) { + + + + } + + - } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - - - -
+ + + + {{ + "criticalApplicationsWithCount" + | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + + +
+
+ }
} } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 0bcc7ba8a0d..3bc968dabc1 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -1,3 +1,4 @@ +import { animate, style, transition, trigger } from "@angular/animations"; import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -34,6 +35,7 @@ import { AllApplicationsComponent } from "./all-applications/all-applications.co import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; +import { PageLoadingComponent } from "./shared/page-loading.component"; import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -55,6 +57,15 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com DrawerHeaderComponent, AllActivityComponent, ApplicationsLoadingComponent, + PageLoadingComponent, + ], + animations: [ + trigger("fadeIn", [ + transition(":enter", [ + style({ opacity: 0 }), + animate("300ms 100ms ease-in", style({ opacity: 1 })), + ]), + ]), ], }) export class RiskInsightsComponent implements OnInit, OnDestroy { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/page-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/page-loading.component.ts new file mode 100644 index 00000000000..41dfa7ff440 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/page-loading.component.ts @@ -0,0 +1,118 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + CardComponent as BitCardComponent, + SkeletonComponent, + SkeletonGroupComponent, + SkeletonTextComponent, +} from "@bitwarden/components"; + +// Page loading component for quick initial loads +// Uses skeleton animations to match the full page layout including header, tabs, and widget cards +// Includes smooth fade-out transition when loading completes +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "dirt-page-loading", + imports: [ + JslibModule, + BitCardComponent, + SkeletonComponent, + SkeletonGroupComponent, + SkeletonTextComponent, + ], + animations: [ + trigger("fadeOut", [transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))])]), + ], + template: ` +
{{ "loading" | i18n }}
+ +
+ +
+ + + + + + + +
+ + + +
+
+ + +
+ +
+ + + +
+ + +
    + +
  • + +
    + + + +
    +
    +
  • + + +
  • + +
    + + + + +
    +
    +
  • + + +
  • + +
    + + + + +
    +
    +
  • + + +
  • + +
    + + + + + + + +
    +
    +
  • +
+
+
+ `, +}) +export class PageLoadingComponent {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html index 9d5712012be..6e6bb786336 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html @@ -1,11 +1,23 @@ -
- - {{ "loading" | i18n }} -

- {{ "generatingYourAccessIntelligence" | i18n }} -

+
+
+ +
+ +
+ + +
+ + {{ currentMessage() | i18n }} + + + {{ "thisMightTakeFewMinutes" | i18n }} + +
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts index d9cd8878b75..d4c97a6fd5c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts @@ -1,15 +1,57 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + ReportProgress, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { ProgressModule } from "@bitwarden/components"; + +const PROGRESS_STEPS = [ + { step: ReportProgress.FetchingMembers, message: "fetchingMemberData", progress: 20 }, + { step: ReportProgress.AnalyzingPasswords, message: "analyzingPasswordHealth", progress: 40 }, + { step: ReportProgress.CalculatingRisks, message: "calculatingRiskScores", progress: 60 }, + { step: ReportProgress.GeneratingReport, message: "generatingReportData", progress: 80 }, + { step: ReportProgress.Saving, message: "savingReport", progress: 95 }, + { step: ReportProgress.Complete, message: "compilingInsights", progress: 100 }, +] as const; + +type LoadingMessage = (typeof PROGRESS_STEPS)[number]["message"]; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dirt-risk-insights-loading", - imports: [CommonModule, JslibModule], + imports: [CommonModule, JslibModule, ProgressModule], templateUrl: "./risk-insights-loading.component.html", }) -export class ApplicationsLoadingComponent { - constructor() {} +export class ApplicationsLoadingComponent implements OnInit { + private dataService = inject(RiskInsightsDataService); + private destroyRef = inject(DestroyRef); + + readonly currentMessage = signal(PROGRESS_STEPS[0].message); + readonly progress = signal(PROGRESS_STEPS[0].progress); + + ngOnInit(): void { + // Subscribe to actual progress events from the orchestrator + this.dataService.reportProgress$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((progressStep) => { + if (progressStep === null) { + // Reset to initial state + this.currentMessage.set(PROGRESS_STEPS[0].message); + this.progress.set(PROGRESS_STEPS[0].progress); + return; + } + + // Find the matching step configuration + const stepConfig = PROGRESS_STEPS.find((config) => config.step === progressStep); + if (stepConfig) { + this.currentMessage.set(stepConfig.message); + this.progress.set(stepConfig.progress); + } + }); + } }