diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 554d0eb31c..7dd0d99a4e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -172,8 +172,8 @@ } } }, - "noAppsInOrgTitle": { - "message": "No applications found in $ORG NAME$", + "noApplicationsInOrgTitle": { + "message": "No applications found for $ORG NAME$", "placeholders": { "org name": { "content": "$1", @@ -181,8 +181,32 @@ } } }, - "noAppsInOrgDescription": { - "message": "As users save logins, applications appear here, showing any at-risk passwords. Mark critical apps and notify users to update passwords." + "noApplicationsInOrgDescription": { + "message": "Import your organization's login data to start monitoring credential security risks. Once imported you get to:" + }, + "benefit1Title": { + "message": "Prioritize risks" + }, + "benefit1Description": { + "message": "Focus on applications that matter the most" + }, + "benefit2Title": { + "message": "Guide remediation" + }, + "benefit2Description": { + "message": "Assign at-risk members guided tasks to rotate at-risk credentials" + }, + "benefit3Title": { + "message": "Monitor progress" + }, + "benefit3Description": { + "message": "Track changes over time to show security improvements" + }, + "noReportRunTitle": { + "message": "Run your first report to see applications" + }, + "noReportRunDescription": { + "message": "Generate a risk insights report to analyze your organization's applications and identify at-risk passwords that need attention. Running your first report will:" }, "noCriticalApplicationsTitle": { "message": "You haven’t marked any applications as critical" diff --git a/apps/web/src/videos/risk-insights-mark-as-critical.mp4 b/apps/web/src/videos/risk-insights-mark-as-critical.mp4 new file mode 100644 index 0000000000..80390804f1 Binary files /dev/null and b/apps/web/src/videos/risk-insights-mark-as-critical.mp4 differ 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 00f8da6f79..79d90f9c8f 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 @@ -114,6 +114,9 @@ export class RiskInsightsOrchestratorService { // --------------------------- Critical Application data --------------------- criticalReportResults$: Observable = of(null); + // --------------------------- Vault Items Check --------------------- + hasVaultItems$: Observable = of(false); + // --------------------------- Trigger subjects --------------------- private _initializeOrganizationTriggerSubject = new Subject(); private _fetchReportTriggerSubject = new Subject(); @@ -139,6 +142,7 @@ export class RiskInsightsOrchestratorService { this._setupCriticalApplicationContext(); this._setupCriticalApplicationReport(); this._setupEnrichedReportData(); + this._setupHasVaultItems(); this._setupInitializationPipeline(); this._setupMigrationAndCleanup(); this._setupReportState(); @@ -711,6 +715,34 @@ export class RiskInsightsOrchestratorService { } // Setup the pipeline to load critical applications when organization or user changes + /** + * Sets up an observable to check if the organization has any vault items (ciphers). + * This is used to determine which empty state to show in the UI. + */ + private _setupHasVaultItems() { + this.hasVaultItems$ = this.organizationDetails$.pipe( + switchMap((orgDetails) => { + if (!orgDetails?.organizationId) { + return of(false); + } + return from( + this.cipherService.getAllFromApiForOrganization(orgDetails.organizationId), + ).pipe( + map((ciphers) => ciphers.length > 0), + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Error checking vault items", + error, + ); + return of(false); + }), + ); + }), + shareReplay({ bufferSize: 1, refCount: true }), + takeUntil(this._destroy$), + ); + } + private _setupCriticalApplicationContext() { this.organizationDetails$ .pipe( @@ -926,8 +958,10 @@ export class RiskInsightsOrchestratorService { // Setup the user ID observable to track the current user private _setupUserId() { // Watch userId changes - this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => { - this._userIdSubject.next(userId); - }); + this.accountService.activeAccount$ + .pipe(getUserId, takeUntil(this._destroy$)) + .subscribe((userId) => { + this._userIdSubject.next(userId); + }); } } 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 4dee99ae04..a827200001 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 @@ -28,6 +28,7 @@ export class RiskInsightsDataService { readonly enrichedReportData$: Observable = of(null); readonly isGeneratingReport$: Observable = of(false); readonly criticalReportResults$: Observable = of(null); + readonly hasVaultItems$: Observable = of(false); // New applications that need review (reviewedDate === null) readonly newApplications$: Observable = of([]); @@ -51,6 +52,7 @@ export class RiskInsightsDataService { this.organizationDetails$ = this.orchestrator.organizationDetails$; this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$; + this.hasVaultItems$ = this.orchestrator.hasVaultItems$; this.newApplications$ = this.orchestrator.newApplications$; // Expose the loading state diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html index 1971b61d51..67d7b4d373 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html @@ -2,101 +2,73 @@ } @else { @let drawerDetails = dataService.drawerDetails$ | async; - @if (!dataSource.data.length) { -
- - -

- {{ - "noAppsInOrgTitle" - | i18n: (dataService.organizationDetails$ | async)?.organizationName || "" - }} -

-
- -
- - {{ "noAppsInOrgDescription" | i18n }} - - {{ "learnMore" | i18n }} -
-
- - - -
+
+

{{ "allApplications" | i18n }}

+
+ + +
+
+ +
- } @else { -
-

{{ "allApplications" | i18n }}

-
- - -
-
- - -
- -
- } + +
} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 5fbc841778..279ddc5e6f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -1,7 +1,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { debounceTime } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; @@ -61,6 +61,7 @@ export class AllApplicationsComponent implements OnInit { protected activatedRoute: ActivatedRoute, protected toastService: ToastService, protected dataService: RiskInsightsDataService, + private router: Router, // protected allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges @@ -78,21 +79,8 @@ export class AllApplicationsComponent implements OnInit { this.dataSource.data = []; }, }); - - // TODO - // this.applicationSummary = this.reportService.generateApplicationsSummary(data); - // this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); } - goToCreateNewLoginItem = async () => { - // TODO: implement - this.toastService.showToast({ - variant: "warning", - title: "", - message: "Not yet implemented", - }); - }; - isMarkedAsCriticalItem(applicationName: string) { return this.selectedUrls.has(applicationName); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html index cfcdf3a184..f0cfd06965 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html @@ -6,26 +6,7 @@ > {{ "loading" | i18n }}
-
- - -

- {{ "noCriticalApplicationsTitle" | i18n }} -

-
- -

- {{ "noCriticalApplicationsDescription" | i18n }} -

-
- - - -
-
-
+

{{ "criticalApplications" | i18n }}

+
+
+ +
+
+ @if (videoSrc()) { + + } @else if (icon()) { +
+ +
+ } +
+
+ +
+
+ @if (videoSrc()) { + + } @else if (icon()) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts new file mode 100644 index 0000000000..54d97e984e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; + +import { Icon } from "@bitwarden/assets/svg"; +import { ButtonModule, IconModule } from "@bitwarden/components"; + +@Component({ + selector: "empty-state-card", + templateUrl: "./empty-state-card.component.html", + imports: [CommonModule, IconModule, ButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmptyStateCardComponent implements OnInit { + readonly icon = input(null); + readonly videoSrc = input(null); + readonly title = input(""); + readonly description = input(""); + readonly benefits = input<[string, string][]>([]); + readonly buttonText = input(""); + readonly buttonAction = input<(() => void) | null>(null); + readonly buttonIcon = input(undefined); + + ngOnInit(): void { + if (!this.title() && isDevMode()) { + // eslint-disable-next-line no-console + console.warn("EmptyStateCardComponent: title is required for proper display"); + } + } +} 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 18df046b82..884501d608 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,64 +1,83 @@ -

{{ "riskInsights" | i18n }}

-
- {{ "reviewAtRiskPasswords" | i18n }} +
+
+

{{ "riskInsights" | i18n }}

+
+ {{ "reviewAtRiskPasswords" | i18n }} +
+ @if (dataLastUpdated) { +
+ + {{ + "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") + }} + @let isRunningReport = dataService.isGeneratingReport$ | async; + + + + + + +
+ } +
+ +
+ @if (shouldShowTabs) { + + @if (isRiskInsightsActivityTabFeatureEnabled) { + + + + } + + + + + + + {{ + "criticalApplicationsWithCount" + | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + + + + } @else { +
+ +
+ } +
-
- - @if (dataLastUpdated) { - {{ - "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") - }} - } @else { - {{ "noReportRan" | i18n }} - } - @let isRunningReport = dataService.isGeneratingReport$ | async; - - - - - - -
- - @if (isRiskInsightsActivityTabFeatureEnabled) { - - - - } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - - - @if (dataService.drawerDetails$ | async; as drawerDetails) { 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 8e58ba2245..5bd5096770 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 @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY } from "rxjs"; +import { combineLatest, EMPTY } from "rxjs"; import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -12,6 +12,7 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; 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 { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, @@ -26,6 +27,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; +import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -38,6 +40,7 @@ import { RiskInsightsTabType } from "./models/risk-insights.models"; ButtonModule, CommonModule, CriticalApplicationsComponent, + EmptyStateCardComponent, JslibModule, HeaderModule, TabsModule, @@ -61,14 +64,36 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { private organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; + refetching: boolean = false; + + // Empty state properties + protected hasReportBeenRun = false; + protected reportHasLoaded = false; + protected hasVaultItems = false; + private organizationName = ""; + + // Empty state computed properties + protected shouldShowImportDataState = false; + protected emptyStateTitle = ""; + protected emptyStateDescription = ""; + protected emptyStateBenefits: [string, string][] = []; + protected emptyStateButtonText = ""; + protected emptyStateButtonIcon = ""; + protected emptyStateButtonAction: (() => void) | null = null; + protected emptyStateVideoSrc: string | null = "/videos/risk-insights-mark-as-critical.mp4"; + + private static readonly IMPORT_ICON = "bwi bwi-download"; + + // TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235 constructor( private route: ActivatedRoute, private router: Router, private configService: ConfigService, protected dataService: RiskInsightsDataService, + private i18nService: I18nService, ) { - this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; }); @@ -89,7 +114,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { tap((orgId) => { if (orgId) { // Initialize Data Service - this.dataService.initializeForOrganization(orgId as OrganizationId); + void this.dataService.initializeForOrganization(orgId as OrganizationId); this.organizationId = orgId as OrganizationId; } else { return EMPTY; @@ -98,12 +123,30 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { ) .subscribe(); - // Subscribe to report result details - this.dataService.enrichedReportData$ + // Combine report data, vault items check, organization details, and generation state + // This declarative pattern ensures proper cleanup and prevents memory leaks + combineLatest([ + this.dataService.enrichedReportData$, + this.dataService.hasVaultItems$, + this.dataService.organizationDetails$, + this.dataService.isGeneratingReport$, + ]) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((report) => { + .subscribe(([report, hasVaultItems, orgDetails, isGenerating]) => { + // Update report state + this.reportHasLoaded = true; + this.hasReportBeenRun = !!report?.creationDate; this.appsCount = report?.reportData.length ?? 0; this.dataLastUpdated = report?.creationDate ?? null; + + // Update vault items state + this.hasVaultItems = hasVaultItems; + + // Update organization name + this.organizationName = orgDetails?.organizationName ?? ""; + + // Update all empty state properties based on current state + this.updateEmptyStateProperties(isGenerating); }); // Subscribe to drawer state changes @@ -128,6 +171,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } } + get shouldShowTabs(): boolean { + return this.appsCount > 0; + } + async onTabChange(newIndex: number): Promise { await this.router.navigate([], { relativeTo: this.route, @@ -166,4 +213,52 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } } } + + // Empty state methods + + // TODO: import data button (we have this) OR button for adding new login items + // we want to add this new button as a second option on the empty state card + + goToImportPage = () => { + void this.router.navigate([ + "/organizations", + this.organizationId, + "settings", + "tools", + "import", + ]); + }; + + /** + * Updates all empty state properties based on current state. + * Called whenever the underlying data (hasVaultItems, hasReportBeenRun, reportHasLoaded) changes. + */ + private updateEmptyStateProperties(isGenerating: boolean): void { + // Calculate boolean flags + // Note: We only show empty states when there are NO apps (appsCount === 0) + // The template uses @if(shouldShowTabs) to determine whether to show tabs or empty state + this.shouldShowImportDataState = !this.hasVaultItems && !isGenerating; + + // Update benefits (constant for all states) + this.emptyStateBenefits = [ + [this.i18nService.t("benefit1Title"), this.i18nService.t("benefit1Description")], + [this.i18nService.t("benefit2Title"), this.i18nService.t("benefit2Description")], + [this.i18nService.t("benefit3Title"), this.i18nService.t("benefit3Description")], + ]; + + // Update all state-dependent properties in single if/else + if (this.shouldShowImportDataState) { + this.emptyStateTitle = this.i18nService.t("noApplicationsInOrgTitle", this.organizationName); + this.emptyStateDescription = this.i18nService.t("noApplicationsInOrgDescription"); + this.emptyStateButtonText = this.i18nService.t("importData"); + this.emptyStateButtonIcon = RiskInsightsComponent.IMPORT_ICON; + this.emptyStateButtonAction = this.goToImportPage; + } else { + this.emptyStateTitle = this.i18nService.t("noReportRunTitle"); + this.emptyStateDescription = this.i18nService.t("noReportRunDescription"); + this.emptyStateButtonText = this.i18nService.t("riskInsightsRunReport"); + this.emptyStateButtonIcon = ""; + this.emptyStateButtonAction = this.generateReport.bind(this); + } + } }