mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[26908] improve empty state design (#16832)
* max init * add mp4 and organize code better * fix lint errors * move empty state logic into risk insights component * replace getter logic * sub for org name * checkForVaultItems fix - need to use cipherservice instead of report results from data service * fix all critical issues mentioned by claude bot * resolve empty state logic bug and memory leaks - Handle zero-results case in empty state logic - Add takeUntil cleanup to _setupUserId subscription - Guard console.warn with isDevMode() check * use tuple arrays for benefits to prevent XSS risk Replace pipe-separated strings with typed tuple arrays [string, string][] for benefits data in empty state component. This eliminates potential XSS risk from string splitting, provides compile-time type safety, and improves performance by removing runtime string parsing on every change detection. * fix(dirt): hide empty states during report generation and fix memory leak Add isGeneratingReport$ to combineLatest, update empty state conditions to check !isGenerating, simplify run report logic, and fix memory leak in route.queryParams subscription. Addresses Claude bot feedback on PR #16832 * refactor(dirt): use signals and OnPush in empty state card component Convert @Input() to readonly input signals and add OnPush change detection strategy. Update template to call signals as functions. Fixes ESLint compliance issues. * refactor(dirt): remove unused shouldShowRunReportState variable The shouldShowRunReportState variable was calculated but never used. The template already uses @else for the run report state, making this variable redundant. * refactor(dirt): consolidate duplicate if statements in empty state logic Merge 5 separate if/else blocks checking shouldShowImportDataState into single consolidated block. Move constant benefits assignment outside conditional. Improves readability and reduces duplication. * remove unnecessary getOrganizationName wrapper method * remove duplicate runReport method Remove runReport arrow function and use generateReport consistently. Both methods called dataService.triggerReport(), but generateReport includes an organizationId check for defensive programming.
This commit is contained in:
@@ -172,8 +172,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noAppsInOrgTitle": {
|
"noApplicationsInOrgTitle": {
|
||||||
"message": "No applications found in $ORG NAME$",
|
"message": "No applications found for $ORG NAME$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"org name": {
|
"org name": {
|
||||||
"content": "$1",
|
"content": "$1",
|
||||||
@@ -181,8 +181,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noAppsInOrgDescription": {
|
"noApplicationsInOrgDescription": {
|
||||||
"message": "As users save logins, applications appear here, showing any at-risk passwords. Mark critical apps and notify users to update passwords."
|
"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": {
|
"noCriticalApplicationsTitle": {
|
||||||
"message": "You haven’t marked any applications as critical"
|
"message": "You haven’t marked any applications as critical"
|
||||||
|
|||||||
BIN
apps/web/src/videos/risk-insights-mark-as-critical.mp4
Normal file
BIN
apps/web/src/videos/risk-insights-mark-as-critical.mp4
Normal file
Binary file not shown.
@@ -114,6 +114,9 @@ export class RiskInsightsOrchestratorService {
|
|||||||
// --------------------------- Critical Application data ---------------------
|
// --------------------------- Critical Application data ---------------------
|
||||||
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||||
|
|
||||||
|
// --------------------------- Vault Items Check ---------------------
|
||||||
|
hasVaultItems$: Observable<boolean> = of(false);
|
||||||
|
|
||||||
// --------------------------- Trigger subjects ---------------------
|
// --------------------------- Trigger subjects ---------------------
|
||||||
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
|
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
|
||||||
private _fetchReportTriggerSubject = new Subject<void>();
|
private _fetchReportTriggerSubject = new Subject<void>();
|
||||||
@@ -139,6 +142,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._setupCriticalApplicationContext();
|
this._setupCriticalApplicationContext();
|
||||||
this._setupCriticalApplicationReport();
|
this._setupCriticalApplicationReport();
|
||||||
this._setupEnrichedReportData();
|
this._setupEnrichedReportData();
|
||||||
|
this._setupHasVaultItems();
|
||||||
this._setupInitializationPipeline();
|
this._setupInitializationPipeline();
|
||||||
this._setupMigrationAndCleanup();
|
this._setupMigrationAndCleanup();
|
||||||
this._setupReportState();
|
this._setupReportState();
|
||||||
@@ -711,6 +715,34 @@ export class RiskInsightsOrchestratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup the pipeline to load critical applications when organization or user changes
|
// 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() {
|
private _setupCriticalApplicationContext() {
|
||||||
this.organizationDetails$
|
this.organizationDetails$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -926,7 +958,9 @@ export class RiskInsightsOrchestratorService {
|
|||||||
// 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
|
||||||
this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => {
|
this.accountService.activeAccount$
|
||||||
|
.pipe(getUserId, takeUntil(this._destroy$))
|
||||||
|
.subscribe((userId) => {
|
||||||
this._userIdSubject.next(userId);
|
this._userIdSubject.next(userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class RiskInsightsDataService {
|
|||||||
readonly enrichedReportData$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
readonly enrichedReportData$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||||
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 hasVaultItems$: Observable<boolean> = of(false);
|
||||||
|
|
||||||
// New applications that need review (reviewedDate === null)
|
// New applications that need review (reviewedDate === null)
|
||||||
readonly newApplications$: Observable<string[]> = of([]);
|
readonly newApplications$: Observable<string[]> = of([]);
|
||||||
@@ -51,6 +52,7 @@ export class RiskInsightsDataService {
|
|||||||
this.organizationDetails$ = this.orchestrator.organizationDetails$;
|
this.organizationDetails$ = this.orchestrator.organizationDetails$;
|
||||||
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
|
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
|
||||||
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
|
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
|
||||||
|
this.hasVaultItems$ = this.orchestrator.hasVaultItems$;
|
||||||
this.newApplications$ = this.orchestrator.newApplications$;
|
this.newApplications$ = this.orchestrator.newApplications$;
|
||||||
|
|
||||||
// Expose the loading state
|
// Expose the loading state
|
||||||
|
|||||||
@@ -2,33 +2,6 @@
|
|||||||
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
||||||
} @else {
|
} @else {
|
||||||
@let drawerDetails = dataService.drawerDetails$ | async;
|
@let drawerDetails = dataService.drawerDetails$ | async;
|
||||||
@if (!dataSource.data.length) {
|
|
||||||
<div class="tw-mt-4">
|
|
||||||
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
|
|
||||||
<ng-container slot="title">
|
|
||||||
<h2 class="tw-font-semibold tw-mt-4">
|
|
||||||
{{
|
|
||||||
"noAppsInOrgTitle"
|
|
||||||
| i18n: (dataService.organizationDetails$ | async)?.organizationName || ""
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container slot="description">
|
|
||||||
<div class="tw-flex tw-flex-col tw-mb-2">
|
|
||||||
<span class="tw-text-muted">
|
|
||||||
{{ "noAppsInOrgDescription" | i18n }}
|
|
||||||
</span>
|
|
||||||
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container slot="button">
|
|
||||||
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button">
|
|
||||||
{{ "createNewLoginItem" | i18n }}
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
</bit-no-items>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="tw-mt-4 tw-flex tw-flex-col">
|
<div class="tw-mt-4 tw-flex tw-flex-col">
|
||||||
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
||||||
<div class="tw-flex tw-gap-6">
|
<div class="tw-flex tw-gap-6">
|
||||||
@@ -98,5 +71,4 @@
|
|||||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||||
></app-table-row-scrollable>
|
></app-table-row-scrollable>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
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 } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { debounceTime } from "rxjs";
|
import { debounceTime } from "rxjs";
|
||||||
|
|
||||||
import { Security } from "@bitwarden/assets/svg";
|
import { Security } from "@bitwarden/assets/svg";
|
||||||
@@ -61,6 +61,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
protected toastService: ToastService,
|
protected toastService: ToastService,
|
||||||
protected dataService: RiskInsightsDataService,
|
protected dataService: RiskInsightsDataService,
|
||||||
|
private router: Router,
|
||||||
// protected allActivitiesService: AllActivitiesService,
|
// protected allActivitiesService: AllActivitiesService,
|
||||||
) {
|
) {
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
@@ -78,21 +79,8 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
this.dataSource.data = [];
|
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) {
|
isMarkedAsCriticalItem(applicationName: string) {
|
||||||
return this.selectedUrls.has(applicationName);
|
return this.selectedUrls.has(applicationName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-mt-4" *ngIf="!dataSource.data.length">
|
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!loading">
|
||||||
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
|
|
||||||
<ng-container slot="title">
|
|
||||||
<h2 class="tw-font-semibold tw-mt-4">
|
|
||||||
{{ "noCriticalApplicationsTitle" | i18n }}
|
|
||||||
</h2>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container slot="description">
|
|
||||||
<p class="tw-text-muted">
|
|
||||||
{{ "noCriticalApplicationsDescription" | i18n }}
|
|
||||||
</p>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container slot="button">
|
|
||||||
<button (click)="goToAllAppsTab()" bitButton buttonType="primary" type="button">
|
|
||||||
{{ "markCriticalApplications" | i18n }}
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
</bit-no-items>
|
|
||||||
</div>
|
|
||||||
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!loading && dataSource.data.length">
|
|
||||||
<div class="tw-flex tw-justify-between tw-mb-4">
|
<div class="tw-flex tw-justify-between tw-mb-4">
|
||||||
<h2 bitTypography="h2">{{ "criticalApplications" | i18n }}</h2>
|
<h2 bitTypography="h2">{{ "criticalApplications" | i18n }}</h2>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<div
|
||||||
|
class="tw-w-full tw-max-w-4xl tw-p-6 sm:tw-p-8 tw-bg-background tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-flex tw-flex-col lg:tw-flex-row tw-gap-6 tw-items-center"
|
||||||
|
>
|
||||||
|
<div class="tw-flex-1 tw-flex tw-flex-col tw-gap-4 sm:tw-gap-5 tw-w-full lg:tw-w-auto">
|
||||||
|
<div
|
||||||
|
class="tw-text-main tw-text-lg sm:tw-text-xl tw-font-semibold tw-leading-6 sm:tw-leading-7"
|
||||||
|
>
|
||||||
|
{{ title() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="tw-text-main tw-text-sm sm:tw-text-base tw-font-normal tw-leading-normal"
|
||||||
|
*ngIf="description()"
|
||||||
|
>
|
||||||
|
{{ description() }}
|
||||||
|
</div>
|
||||||
|
@if (benefits().length > 0) {
|
||||||
|
<div class="tw-flex tw-flex-col tw-gap-4 sm:tw-gap-5">
|
||||||
|
@for (benefit of benefits(); track $index) {
|
||||||
|
<div class="tw-flex tw-items-start tw-gap-3">
|
||||||
|
<div
|
||||||
|
class="tw-size-8 sm:tw-size-9 tw-bg-secondary-100 tw-rounded-full tw-flex tw-justify-center tw-items-center tw-flex-shrink-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tw-text-center tw-text-main tw-text-sm sm:tw-text-base tw-font-bold tw-leading-normal"
|
||||||
|
>
|
||||||
|
{{ $index + 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tw-flex-1 tw-pt-1 sm:tw-pt-1.5 tw-flex tw-flex-col tw-gap-1">
|
||||||
|
<div
|
||||||
|
class="tw-text-main tw-text-sm sm:tw-text-base tw-font-semibold tw-leading-normal"
|
||||||
|
>
|
||||||
|
{{ benefit[0] }}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-main tw-text-xs tw-font-normal tw-leading-none">
|
||||||
|
{{ benefit[1] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="tw-flex tw-justify-start" *ngIf="buttonText() && buttonAction()">
|
||||||
|
<button
|
||||||
|
(click)="buttonAction()()"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
type="button"
|
||||||
|
class="tw-px-3 tw-py-1.5 sm:tw-px-4 tw-rounded-full tw-text-sm sm:tw-text-base"
|
||||||
|
>
|
||||||
|
<i [class]="buttonIcon() + ' tw-mr-2'" *ngIf="buttonIcon()"></i>
|
||||||
|
{{ buttonText() }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-hidden lg:tw-block tw-flex-shrink-0" *ngIf="videoSrc() || icon()">
|
||||||
|
<div class="tw-size-64 xl:tw-size-80 tw-relative">
|
||||||
|
@if (videoSrc()) {
|
||||||
|
<video
|
||||||
|
class="tw-size-full tw-rounded-lg"
|
||||||
|
[src]="videoSrc()"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
aria-hidden="true"
|
||||||
|
></video>
|
||||||
|
} @else if (icon()) {
|
||||||
|
<div
|
||||||
|
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||||
|
>
|
||||||
|
<bit-icon
|
||||||
|
[icon]="icon()"
|
||||||
|
class="tw-size-16 xl:tw-size-24 tw-text-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
></bit-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-flex lg:tw-hidden tw-w-full tw-justify-center" *ngIf="videoSrc() || icon()">
|
||||||
|
<div class="tw-size-48 sm:tw-size-64 tw-relative">
|
||||||
|
@if (videoSrc()) {
|
||||||
|
<video
|
||||||
|
class="tw-size-full tw-rounded-lg"
|
||||||
|
[src]="videoSrc()"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
aria-hidden="true"
|
||||||
|
></video>
|
||||||
|
} @else if (icon()) {
|
||||||
|
<div
|
||||||
|
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||||
|
>
|
||||||
|
<bit-icon
|
||||||
|
[icon]="icon()"
|
||||||
|
class="tw-size-12 sm:tw-size-16 tw-text-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
></bit-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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<Icon | null>(null);
|
||||||
|
readonly videoSrc = input<string | null>(null);
|
||||||
|
readonly title = input<string>("");
|
||||||
|
readonly description = input<string>("");
|
||||||
|
readonly benefits = input<[string, string][]>([]);
|
||||||
|
readonly buttonText = input<string>("");
|
||||||
|
readonly buttonAction = input<(() => void) | null>(null);
|
||||||
|
readonly buttonIcon = input<string | undefined>(undefined);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.title() && isDevMode()) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("EmptyStateCardComponent: title is required for proper display");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
|
<div class="tw-min-h-screen tw-flex tw-flex-col">
|
||||||
|
<div>
|
||||||
<h1 bitTypography="h1">{{ "riskInsights" | i18n }}</h1>
|
<h1 bitTypography="h1">{{ "riskInsights" | i18n }}</h1>
|
||||||
<div class="tw-text-main tw-max-w-4xl tw-mb-2">
|
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="shouldShowTabs">
|
||||||
{{ "reviewAtRiskPasswords" | i18n }}
|
{{ "reviewAtRiskPasswords" | i18n }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (dataLastUpdated) {
|
||||||
<div
|
<div
|
||||||
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
|
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
|
||||||
>
|
>
|
||||||
@@ -10,13 +13,9 @@
|
|||||||
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
|
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i>
|
||||||
@if (dataLastUpdated) {
|
|
||||||
<span class="tw-mx-4">{{
|
<span class="tw-mx-4">{{
|
||||||
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
|
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
|
||||||
}}</span>
|
}}</span>
|
||||||
} @else {
|
|
||||||
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
|
|
||||||
}
|
|
||||||
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
||||||
<span class="tw-flex tw-justify-center">
|
<span class="tw-flex tw-justify-center">
|
||||||
<button
|
<button
|
||||||
@@ -39,6 +38,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-flex-1 tw-flex tw-flex-col">
|
||||||
|
@if (shouldShowTabs) {
|
||||||
<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 }}">
|
||||||
@@ -59,6 +63,21 @@
|
|||||||
<dirt-critical-applications></dirt-critical-applications>
|
<dirt-critical-applications></dirt-critical-applications>
|
||||||
</bit-tab>
|
</bit-tab>
|
||||||
</bit-tab-group>
|
</bit-tab-group>
|
||||||
|
} @else {
|
||||||
|
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
|
||||||
|
<empty-state-card
|
||||||
|
[videoSrc]="emptyStateVideoSrc"
|
||||||
|
[title]="emptyStateTitle"
|
||||||
|
[description]="emptyStateDescription"
|
||||||
|
[benefits]="emptyStateBenefits"
|
||||||
|
[buttonText]="emptyStateButtonText"
|
||||||
|
[buttonIcon]="emptyStateButtonIcon"
|
||||||
|
[buttonAction]="emptyStateButtonAction"
|
||||||
|
></empty-state-card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (dataService.drawerDetails$ | async; as drawerDetails) {
|
@if (dataService.drawerDetails$ | async; as drawerDetails) {
|
||||||
<bit-drawer style="width: 30%" [(open)]="isDrawerOpen" (openChange)="dataService.closeDrawer()">
|
<bit-drawer style="width: 30%" [(open)]="isDrawerOpen" (openChange)="dataService.closeDrawer()">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
|
|||||||
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
|
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { EMPTY } from "rxjs";
|
import { combineLatest, EMPTY } from "rxjs";
|
||||||
import { map, tap } from "rxjs/operators";
|
import { map, tap } from "rxjs/operators";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
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 { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import {
|
import {
|
||||||
AsyncActionsModule,
|
AsyncActionsModule,
|
||||||
@@ -26,6 +27,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
|
|||||||
import { AllActivityComponent } from "./activity/all-activity.component";
|
import { AllActivityComponent } from "./activity/all-activity.component";
|
||||||
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
||||||
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||||
|
import { EmptyStateCardComponent } from "./empty-state-card.component";
|
||||||
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
@@ -38,6 +40,7 @@ import { RiskInsightsTabType } from "./models/risk-insights.models";
|
|||||||
ButtonModule,
|
ButtonModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
CriticalApplicationsComponent,
|
CriticalApplicationsComponent,
|
||||||
|
EmptyStateCardComponent,
|
||||||
JslibModule,
|
JslibModule,
|
||||||
HeaderModule,
|
HeaderModule,
|
||||||
TabsModule,
|
TabsModule,
|
||||||
@@ -61,14 +64,36 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
private organizationId: OrganizationId = "" as OrganizationId;
|
private organizationId: OrganizationId = "" as OrganizationId;
|
||||||
|
|
||||||
dataLastUpdated: Date | null = null;
|
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(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
protected dataService: RiskInsightsDataService,
|
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;
|
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +114,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
tap((orgId) => {
|
tap((orgId) => {
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
// Initialize Data Service
|
// Initialize Data Service
|
||||||
this.dataService.initializeForOrganization(orgId as OrganizationId);
|
void this.dataService.initializeForOrganization(orgId as OrganizationId);
|
||||||
this.organizationId = orgId as OrganizationId;
|
this.organizationId = orgId as OrganizationId;
|
||||||
} else {
|
} else {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
@@ -98,12 +123,30 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
// Subscribe to report result details
|
// Combine report data, vault items check, organization details, and generation state
|
||||||
this.dataService.enrichedReportData$
|
// 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))
|
.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.appsCount = report?.reportData.length ?? 0;
|
||||||
this.dataLastUpdated = report?.creationDate ?? null;
|
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
|
// 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<void> {
|
async onTabChange(newIndex: number): Promise<void> {
|
||||||
await this.router.navigate([], {
|
await this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user