mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM-27694] Handle empty report response (#17162)
* Consolidate loading state and handle null report from api response * Fix jumping of page when ciphers are still loading * Fix type errors * Fix loading state
This commit is contained in:
@@ -98,6 +98,15 @@ export type ReportResult = CipherView & {
|
|||||||
scoreKey: number;
|
scoreKey: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ReportStatus = Object.freeze({
|
||||||
|
Initializing: 1,
|
||||||
|
Loading: 2,
|
||||||
|
Complete: 3,
|
||||||
|
Error: 4,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus];
|
||||||
|
|
||||||
export interface RiskInsightsData {
|
export interface RiskInsightsData {
|
||||||
id: OrganizationReportId;
|
id: OrganizationReportId;
|
||||||
creationDate: Date;
|
creationDate: Date;
|
||||||
@@ -108,10 +117,9 @@ export interface RiskInsightsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportState {
|
export interface ReportState {
|
||||||
loading: boolean;
|
status: ReportStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
data: RiskInsightsData | null;
|
data: RiskInsightsData | null;
|
||||||
organizationId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Make Versioned models for structure changes
|
// TODO Make Versioned models for structure changes
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export class RiskInsightsApiService {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
return from(dbResponse).pipe(
|
return from(dbResponse).pipe(
|
||||||
map((response) => new GetRiskInsightsReportResponse(response)),
|
// As of this change, the server doesn't return a 404 if a report is not found
|
||||||
|
// Handle null response if server returns nothing
|
||||||
|
map((response) => (response ? new GetRiskInsightsReportResponse(response) : null)),
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||||
return of(null); // Handle 404 by returning null or an appropriate default value
|
return of(null); // Handle 404 by returning null or an appropriate default value
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
|||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import { createNewSummaryData } from "../../helpers";
|
import { createNewSummaryData } from "../../helpers";
|
||||||
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
|
import { ReportStatus, RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
|
||||||
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||||
import {
|
import {
|
||||||
@@ -105,34 +105,6 @@ describe("RiskInsightsOrchestratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchReport", () => {
|
describe("fetchReport", () => {
|
||||||
it("should call with correct org and user IDs and emit ReportState", (done) => {
|
|
||||||
// Arrange
|
|
||||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
|
||||||
const privateUserIdSubject = service["_userIdSubject"];
|
|
||||||
|
|
||||||
// Set up organization and user context
|
|
||||||
privateOrganizationDetailsSubject.next({
|
|
||||||
organizationId: mockOrgId,
|
|
||||||
organizationName: mockOrgName,
|
|
||||||
});
|
|
||||||
privateUserIdSubject.next(mockUserId);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
service.fetchReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
service.rawReportData$.subscribe((state) => {
|
|
||||||
if (!state.loading) {
|
|
||||||
expect(mockReportService.getRiskInsightsReport$).toHaveBeenCalledWith(
|
|
||||||
mockOrgId,
|
|
||||||
mockUserId,
|
|
||||||
);
|
|
||||||
expect(state.data).toEqual(reportState);
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => {
|
it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => {
|
||||||
// Setup error passed via constructor for this test case
|
// Setup error passed via constructor for this test case
|
||||||
mockReportService.getRiskInsightsReport$ = jest
|
mockReportService.getRiskInsightsReport$ = jest
|
||||||
@@ -157,9 +129,8 @@ describe("RiskInsightsOrchestratorService", () => {
|
|||||||
organizationName: mockOrgName,
|
organizationName: mockOrgName,
|
||||||
});
|
});
|
||||||
_userIdSubject.next(mockUserId);
|
_userIdSubject.next(mockUserId);
|
||||||
testService.fetchReport();
|
|
||||||
testService.rawReportData$.subscribe((state) => {
|
testService.rawReportData$.subscribe((state) => {
|
||||||
if (!state.loading) {
|
if (state.status != ReportStatus.Loading) {
|
||||||
expect(state.error).toBe("Failed to fetch report");
|
expect(state.error).toBe("Failed to fetch report");
|
||||||
expect(state.data).toBeNull();
|
expect(state.data).toBeNull();
|
||||||
done();
|
done();
|
||||||
@@ -199,7 +170,7 @@ describe("RiskInsightsOrchestratorService", () => {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
service.rawReportData$.subscribe((state) => {
|
service.rawReportData$.subscribe((state) => {
|
||||||
if (!state.loading && state.data) {
|
if (state.status != ReportStatus.Loading && state.data) {
|
||||||
expect(mockMemberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith(
|
expect(mockMemberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith(
|
||||||
mockOrgId,
|
mockOrgId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
MemberDetails,
|
MemberDetails,
|
||||||
OrganizationReportApplication,
|
OrganizationReportApplication,
|
||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
|
ReportStatus,
|
||||||
ReportState,
|
ReportState,
|
||||||
} from "../../models/report-models";
|
} from "../../models/report-models";
|
||||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||||
@@ -79,13 +80,16 @@ export class RiskInsightsOrchestratorService {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
organizationDetails$ = this._organizationDetailsSubject.asObservable();
|
organizationDetails$ = this._organizationDetailsSubject.asObservable();
|
||||||
|
|
||||||
// ------------------------- Raw data -------------------------
|
// ------------------------- Cipher data -------------------------
|
||||||
private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
|
private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||||
private _ciphers$ = this._ciphersSubject.asObservable();
|
private _ciphers$ = this._ciphersSubject.asObservable();
|
||||||
|
|
||||||
|
private _hasCiphersSubject$ = new BehaviorSubject<boolean | null>(null);
|
||||||
|
hasCiphers$ = this._hasCiphersSubject$.asObservable();
|
||||||
|
|
||||||
// ------------------------- Report Variables ----------------
|
// ------------------------- Report Variables ----------------
|
||||||
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
|
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
|
||||||
loading: true,
|
status: ReportStatus.Initializing,
|
||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
@@ -116,14 +120,10 @@ 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 _flagForUpdatesSubject = new Subject<ReportState>();
|
||||||
private _markUnmarkUpdatesSubject = new Subject<ReportState>();
|
private _flagForUpdates$ = this._flagForUpdatesSubject.asObservable();
|
||||||
private _markUnmarkUpdates$ = this._markUnmarkUpdatesSubject.asObservable();
|
|
||||||
|
|
||||||
private _reportStateSubscription: Subscription | null = null;
|
private _reportStateSubscription: Subscription | null = null;
|
||||||
private _migrationSubscription: Subscription | null = null;
|
private _migrationSubscription: Subscription | null = null;
|
||||||
@@ -144,7 +144,6 @@ 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();
|
||||||
@@ -163,14 +162,6 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._destroy$.complete();
|
this._destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the latest report for the current organization and user
|
|
||||||
*/
|
|
||||||
fetchReport(): void {
|
|
||||||
this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered");
|
|
||||||
this._fetchReportTriggerSubject.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new report for the current organization and user
|
* Generates a new report for the current organization and user
|
||||||
*/
|
*/
|
||||||
@@ -187,7 +178,6 @@ export class RiskInsightsOrchestratorService {
|
|||||||
initializeForOrganization(organizationId: OrganizationId) {
|
initializeForOrganization(organizationId: OrganizationId) {
|
||||||
this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId);
|
this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId);
|
||||||
this._initializeOrganizationTriggerSubject.next(organizationId);
|
this._initializeOrganizationTriggerSubject.next(organizationId);
|
||||||
this.fetchReport();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,7 +192,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
);
|
);
|
||||||
return this.rawReportData$.pipe(
|
return this.rawReportData$.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
filter((data) => !data.loading && data.data != null),
|
filter((data) => data.status != ReportStatus.Loading && data.data != null),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
||||||
this._userId$.pipe(filter((userId) => !!userId)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
@@ -311,9 +301,8 @@ export class RiskInsightsOrchestratorService {
|
|||||||
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
||||||
map(() => updatedState),
|
map(() => updatedState),
|
||||||
tap((finalState) => {
|
tap((finalState) => {
|
||||||
this._markUnmarkUpdatesSubject.next({
|
this._flagForUpdatesSubject.next({
|
||||||
...finalState,
|
...finalState,
|
||||||
organizationId: reportState.organizationId,
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
@@ -331,7 +320,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
);
|
);
|
||||||
return this.rawReportData$.pipe(
|
return this.rawReportData$.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
filter((data) => !data.loading && data.data != null),
|
filter((data) => data.status != ReportStatus.Loading && data.data != null),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
||||||
this._userId$.pipe(filter((userId) => !!userId)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
@@ -440,9 +429,8 @@ export class RiskInsightsOrchestratorService {
|
|||||||
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
||||||
map(() => updatedState),
|
map(() => updatedState),
|
||||||
tap((finalState) => {
|
tap((finalState) => {
|
||||||
this._markUnmarkUpdatesSubject.next({
|
this._flagForUpdatesSubject.next({
|
||||||
...finalState,
|
...finalState,
|
||||||
organizationId: reportState.organizationId,
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
@@ -470,7 +458,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
|
|
||||||
return this.rawReportData$.pipe(
|
return this.rawReportData$.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
filter((data) => !data.loading && data.data != null),
|
filter((data) => data.status != ReportStatus.Loading && data.data != null),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
|
||||||
this._userId$.pipe(filter((userId) => !!userId)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
@@ -545,9 +533,6 @@ export class RiskInsightsOrchestratorService {
|
|||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(() => updatedState),
|
map(() => updatedState),
|
||||||
tap((finalState) => {
|
|
||||||
this._markUnmarkUpdatesSubject.next(finalState);
|
|
||||||
}),
|
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
this.logService.error(
|
this.logService.error(
|
||||||
"[RiskInsightsOrchestratorService] Failed to save review status",
|
"[RiskInsightsOrchestratorService] Failed to save review status",
|
||||||
@@ -565,16 +550,20 @@ export class RiskInsightsOrchestratorService {
|
|||||||
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")),
|
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")),
|
||||||
map((result): ReportState => {
|
map((result): ReportState => {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
status: ReportStatus.Complete,
|
||||||
error: null,
|
error: null,
|
||||||
data: result ?? null,
|
data: result,
|
||||||
organizationId,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
catchError(() =>
|
catchError((error: unknown) => {
|
||||||
of({ loading: false, error: "Failed to fetch report", data: null, organizationId }),
|
this.logService.error("[RiskInsightsOrchestratorService] Failed to fetch report", error);
|
||||||
),
|
return of({
|
||||||
startWith({ loading: true, error: null, data: null, organizationId }),
|
status: ReportStatus.Error,
|
||||||
|
error: "Failed to fetch report",
|
||||||
|
data: null,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +639,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
map((mappedResult): ReportState => {
|
map((mappedResult): ReportState => {
|
||||||
const { id, report, summary, applications, contentEncryptionKey } = mappedResult;
|
const { id, report, summary, applications, contentEncryptionKey } = mappedResult;
|
||||||
return {
|
return {
|
||||||
loading: false,
|
status: ReportStatus.Complete,
|
||||||
error: null,
|
error: null,
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
@@ -660,18 +649,20 @@ export class RiskInsightsOrchestratorService {
|
|||||||
creationDate: new Date(),
|
creationDate: new Date(),
|
||||||
contentEncryptionKey,
|
contentEncryptionKey,
|
||||||
},
|
},
|
||||||
organizationId,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
catchError((): Observable<ReportState> => {
|
catchError((): Observable<ReportState> => {
|
||||||
return of({
|
return of({
|
||||||
loading: false,
|
status: ReportStatus.Error,
|
||||||
error: "Failed to generate or save report",
|
error: "Failed to generate or save report",
|
||||||
data: null,
|
data: null,
|
||||||
organizationId,
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
startWith<ReportState>({ loading: true, error: null, data: null, organizationId }),
|
startWith<ReportState>({
|
||||||
|
status: ReportStatus.Loading,
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,34 +870,6 @@ 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(
|
||||||
@@ -1003,6 +966,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
orgDetails.organizationId,
|
orgDetails.organizationId,
|
||||||
);
|
);
|
||||||
this._ciphersSubject.next(ciphers);
|
this._ciphersSubject.next(ciphers);
|
||||||
|
this._hasCiphersSubject$.next(ciphers.length > 0);
|
||||||
}),
|
}),
|
||||||
takeUntil(this._destroy$),
|
takeUntil(this._destroy$),
|
||||||
)
|
)
|
||||||
@@ -1052,28 +1016,28 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._userId$.pipe(filter((user) => !!user)),
|
this._userId$.pipe(filter((user) => !!user)),
|
||||||
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||||
|
|
||||||
// A stream for the initial report fetch
|
// A stream that continuously watches dependencies and fetches a new report every time they change
|
||||||
const initialReportLoad$ = reportDependencies$.pipe(
|
const continuousReportFetch$: Observable<ReportState> = reportDependencies$.pipe(
|
||||||
take(1),
|
switchMap(([orgDetails, userId]) =>
|
||||||
exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)),
|
this._fetchReport$(orgDetails!.organizationId, userId!).pipe(
|
||||||
);
|
startWith<ReportState>({ status: ReportStatus.Initializing, error: null, data: null }),
|
||||||
|
),
|
||||||
// A stream for manually triggered fetches
|
|
||||||
const manualReportFetch$ = this._fetchReportTriggerSubject.pipe(
|
|
||||||
withLatestFrom(reportDependencies$),
|
|
||||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
|
||||||
this._fetchReport$(orgDetails!.organizationId, userId!),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// A stream for generating a new report
|
// A stream for generating a new report
|
||||||
const newReportGeneration$ = this.generatingReport$.pipe(
|
const newReportGeneration$: Observable<ReportState> = this.generatingReport$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
filter((isRunning) => isRunning),
|
filter((isRunning) => isRunning),
|
||||||
withLatestFrom(reportDependencies$),
|
withLatestFrom(reportDependencies$),
|
||||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
exhaustMap(([_, [orgDetails, userId]]) =>
|
||||||
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!),
|
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!),
|
||||||
),
|
),
|
||||||
|
startWith<ReportState>({
|
||||||
|
status: ReportStatus.Loading,
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this._generateReportTriggerSubject.next(false);
|
this._generateReportTriggerSubject.next(false);
|
||||||
}),
|
}),
|
||||||
@@ -1081,30 +1045,44 @@ export class RiskInsightsOrchestratorService {
|
|||||||
|
|
||||||
// Combine all triggers and update the single report state
|
// Combine all triggers and update the single report state
|
||||||
const mergedReportState$ = merge(
|
const mergedReportState$ = merge(
|
||||||
initialReportLoad$,
|
continuousReportFetch$,
|
||||||
manualReportFetch$,
|
|
||||||
newReportGeneration$,
|
newReportGeneration$,
|
||||||
this._markUnmarkUpdates$,
|
this._flagForUpdates$,
|
||||||
).pipe(
|
).pipe(
|
||||||
scan((prevState: ReportState, currState: ReportState) => {
|
startWith<ReportState>({
|
||||||
// If organization changed, use new state completely (don't preserve old data)
|
status: ReportStatus.Initializing,
|
||||||
// This allows null data to clear old org's data when switching orgs
|
error: null,
|
||||||
if (currState.organizationId && prevState.organizationId !== currState.organizationId) {
|
data: null,
|
||||||
return {
|
}),
|
||||||
...currState,
|
withLatestFrom(this.organizationDetails$),
|
||||||
data: currState.data, // Allow null to clear old org's data
|
map(([reportState, orgDetails]) => {
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same org (or no org ID): preserve data when currState.data is null
|
|
||||||
// This preserves critical flags during loading states within the same org
|
|
||||||
return {
|
return {
|
||||||
...prevState,
|
reportState,
|
||||||
...currState,
|
organizationId: orgDetails?.organizationId,
|
||||||
data: currState.data !== null ? currState.data : prevState.data,
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 3. NOW, scan receives a simple object for both prevState and currState
|
||||||
|
scan((prevState, currState) => {
|
||||||
|
const hasOrganizationChanged = prevState.organizationId !== currState.organizationId;
|
||||||
|
// Don't override initial status until complete
|
||||||
|
const keepInitializeStatus =
|
||||||
|
prevState.reportState.status == ReportStatus.Initializing &&
|
||||||
|
currState.reportState.status == ReportStatus.Loading;
|
||||||
|
return {
|
||||||
|
reportState: {
|
||||||
|
status: keepInitializeStatus
|
||||||
|
? prevState.reportState.status
|
||||||
|
: (currState.reportState.status ?? prevState.reportState.status),
|
||||||
|
error: currState.reportState.error ?? prevState.reportState.error,
|
||||||
|
data:
|
||||||
|
currState.reportState.data !== null || hasOrganizationChanged
|
||||||
|
? currState.reportState.data
|
||||||
|
: prevState.reportState.data,
|
||||||
|
},
|
||||||
|
organizationId: currState.organizationId,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
startWith({ loading: false, error: null, data: null }),
|
|
||||||
shareReplay({ bufferSize: 1, refCount: true }),
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
takeUntil(this._destroy$),
|
takeUntil(this._destroy$),
|
||||||
);
|
);
|
||||||
@@ -1112,7 +1090,7 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._reportStateSubscription = mergedReportState$
|
this._reportStateSubscription = mergedReportState$
|
||||||
.pipe(takeUntil(this._destroy$))
|
.pipe(takeUntil(this._destroy$))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
this._rawReportDataSubject.next(state);
|
this._rawReportDataSubject.next(state.reportState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { catchError, EMPTY, from, map, Observable, switchMap, throwError } from "rxjs";
|
import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs";
|
||||||
|
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
||||||
@@ -146,12 +146,12 @@ export class RiskInsightsReportService {
|
|||||||
getRiskInsightsReport$(
|
getRiskInsightsReport$(
|
||||||
organizationId: OrganizationId,
|
organizationId: OrganizationId,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
): Observable<RiskInsightsData> {
|
): Observable<RiskInsightsData | null> {
|
||||||
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
|
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
|
||||||
switchMap((response) => {
|
switchMap((response) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
// Return an empty report and summary if response is falsy
|
// Return an empty report and summary if response is falsy
|
||||||
return EMPTY;
|
return of(null as unknown as RiskInsightsData);
|
||||||
}
|
}
|
||||||
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
|
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
|
||||||
return throwError(() => new Error("Report key not found"));
|
return throwError(() => new Error("Report key not found"));
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { distinctUntilChanged, map } from "rxjs/operators";
|
|||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
||||||
import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models";
|
import {
|
||||||
|
ReportState,
|
||||||
|
DrawerDetails,
|
||||||
|
DrawerType,
|
||||||
|
RiskInsightsEnrichedData,
|
||||||
|
ReportStatus,
|
||||||
|
} from "../../models";
|
||||||
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
||||||
|
|
||||||
export class RiskInsightsDataService {
|
export class RiskInsightsDataService {
|
||||||
@@ -24,11 +30,12 @@ export class RiskInsightsDataService {
|
|||||||
// -------------------------- Orchestrator-driven state -------------
|
// -------------------------- Orchestrator-driven state -------------
|
||||||
// The full report state (for internal facade use or complex components)
|
// The full report state (for internal facade use or complex components)
|
||||||
private readonly reportState$: Observable<ReportState>;
|
private readonly reportState$: Observable<ReportState>;
|
||||||
readonly isLoading$: Observable<boolean> = of(false);
|
readonly reportStatus$: Observable<ReportStatus> = of(ReportStatus.Initializing);
|
||||||
|
readonly hasReportData$: Observable<boolean> = of(false);
|
||||||
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);
|
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
||||||
|
|
||||||
// New applications that need review (reviewedDate === null)
|
// New applications that need review (reviewedDate === null)
|
||||||
readonly newApplications$: Observable<string[]> = of([]);
|
readonly newApplications$: Observable<string[]> = of([]);
|
||||||
@@ -52,14 +59,19 @@ 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$;
|
||||||
|
|
||||||
|
this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged());
|
||||||
|
|
||||||
// Expose the loading state
|
// Expose the loading state
|
||||||
this.isLoading$ = this.reportState$.pipe(
|
this.reportStatus$ = this.reportState$.pipe(
|
||||||
map((state) => state.loading),
|
map((state) => state.status),
|
||||||
distinctUntilChanged(), // Prevent unnecessary component re-renders
|
distinctUntilChanged(), // Prevent unnecessary component re-renders
|
||||||
);
|
);
|
||||||
|
this.hasReportData$ = this.reportState$.pipe(
|
||||||
|
map((state) => state.data != null),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
@@ -76,10 +88,6 @@ export class RiskInsightsDataService {
|
|||||||
this.orchestrator.generateReport();
|
this.orchestrator.generateReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchReport(): void {
|
|
||||||
this.orchestrator.fetchReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------- Drawer functions -----------------------------
|
// ------------------------- Drawer functions -----------------------------
|
||||||
isActiveDrawerType = (drawerType: DrawerType): boolean => {
|
isActiveDrawerType = (drawerType: DrawerType): boolean => {
|
||||||
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
|
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@if (dataService.isLoading$ | async) {
|
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
|
||||||
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
||||||
} @else {
|
} @else {
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
|
ReportStatus,
|
||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
@@ -44,6 +45,8 @@ export class AllActivityComponent implements OnInit {
|
|||||||
|
|
||||||
destroyRef = inject(DestroyRef);
|
destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
protected ReportStatusEnum = ReportStatus;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@if (dataService.isLoading$ | async) {
|
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
|
||||||
<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;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||||
import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
import {
|
||||||
|
OrganizationReportSummary,
|
||||||
|
ReportStatus,
|
||||||
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +56,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
noItemsIcon = Security;
|
noItemsIcon = Security;
|
||||||
protected markingAsCritical = false;
|
protected markingAsCritical = false;
|
||||||
protected applicationSummary: OrganizationReportSummary = createNewSummaryData();
|
protected applicationSummary: OrganizationReportSummary = createNewSummaryData();
|
||||||
|
protected ReportStatusEnum = ReportStatus;
|
||||||
|
|
||||||
destroyRef = inject(DestroyRef);
|
destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
<div *ngIf="loading">
|
<div class="tw-mt-4 tw-flex tw-flex-col">
|
||||||
<i
|
|
||||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
|
||||||
title="{{ 'loading' | i18n }}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!loading">
|
|
||||||
<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
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks
|
|||||||
})
|
})
|
||||||
export class CriticalApplicationsComponent implements OnInit {
|
export class CriticalApplicationsComponent implements OnInit {
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
protected loading = false;
|
|
||||||
protected enableRequestPasswordChange = false;
|
protected enableRequestPasswordChange = false;
|
||||||
protected organizationId: OrganizationId;
|
protected organizationId: OrganizationId;
|
||||||
noItemsIcon = Security;
|
noItemsIcon = Security;
|
||||||
|
|||||||
@@ -1,83 +1,106 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<div class="tw-min-h-screen tw-flex tw-flex-col">
|
@let status = dataService.reportStatus$ | async;
|
||||||
<div>
|
@let hasCiphers = dataService.hasCiphers$ | async;
|
||||||
<h1 bitTypography="h1">{{ "riskInsights" | i18n }}</h1>
|
@if (status == ReportStatusEnum.Initializing || hasCiphers === null) {
|
||||||
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="shouldShowTabs">
|
<!-- Show loading state when initializing risk insights -->
|
||||||
{{ "reviewAtRiskPasswords" | i18n }}
|
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
||||||
</div>
|
} @else {
|
||||||
@if (dataLastUpdated) {
|
<!-- Check final states after initial calls have been completed -->
|
||||||
<div
|
@if (!(dataService.hasReportData$ | async)) {
|
||||||
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"
|
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
|
||||||
>
|
@if (!hasCiphers) {
|
||||||
<i
|
<!-- Show Empty state when there are no applications (no ciphers to make reports on) -->
|
||||||
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
<span class="tw-mx-4">{{
|
|
||||||
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
|
|
||||||
}}</span>
|
|
||||||
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
|
||||||
<span class="tw-flex tw-justify-center">
|
|
||||||
<button
|
|
||||||
*ngIf="!isRunningReport"
|
|
||||||
type="button"
|
|
||||||
bitButton
|
|
||||||
buttonType="secondary"
|
|
||||||
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
|
|
||||||
tabindex="0"
|
|
||||||
[bitAction]="generateReport.bind(this)"
|
|
||||||
>
|
|
||||||
{{ "riskInsightsRunReport" | i18n }}
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
<i
|
|
||||||
*ngIf="isRunningReport"
|
|
||||||
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tw-flex-1 tw-flex tw-flex-col">
|
|
||||||
@if (shouldShowTabs) {
|
|
||||||
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
|
||||||
@if (isRiskInsightsActivityTabFeatureEnabled) {
|
|
||||||
<bit-tab label="{{ 'activity' | i18n }}">
|
|
||||||
<dirt-all-activity></dirt-all-activity>
|
|
||||||
</bit-tab>
|
|
||||||
}
|
|
||||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
|
||||||
<dirt-all-applications></dirt-all-applications>
|
|
||||||
</bit-tab>
|
|
||||||
<bit-tab>
|
|
||||||
<ng-template bitTabLabel>
|
|
||||||
<i class="bwi bwi-star"></i>
|
|
||||||
{{
|
|
||||||
"criticalApplicationsWithCount"
|
|
||||||
| i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0
|
|
||||||
}}
|
|
||||||
</ng-template>
|
|
||||||
<dirt-critical-applications></dirt-critical-applications>
|
|
||||||
</bit-tab>
|
|
||||||
</bit-tab-group>
|
|
||||||
} @else {
|
|
||||||
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
|
|
||||||
<empty-state-card
|
<empty-state-card
|
||||||
[videoSrc]="emptyStateVideoSrc"
|
[videoSrc]="emptyStateVideoSrc"
|
||||||
[title]="emptyStateTitle"
|
[title]="this.i18nService.t('noApplicationsInOrgTitle', organizationName)"
|
||||||
[description]="emptyStateDescription"
|
[description]="this.i18nService.t('noApplicationsInOrgDescription')"
|
||||||
[benefits]="emptyStateBenefits"
|
[benefits]="emptyStateBenefits"
|
||||||
[buttonText]="emptyStateButtonText"
|
[buttonText]="this.i18nService.t('importData')"
|
||||||
[buttonIcon]="emptyStateButtonIcon"
|
[buttonIcon]="IMPORT_ICON"
|
||||||
[buttonAction]="emptyStateButtonAction"
|
[buttonAction]="this.goToImportPage"
|
||||||
></empty-state-card>
|
></empty-state-card>
|
||||||
|
} @else {
|
||||||
|
<!-- Show empty state for no reports run -->
|
||||||
|
<empty-state-card
|
||||||
|
[videoSrc]="emptyStateVideoSrc"
|
||||||
|
[title]="this.i18nService.t('noReportRunTitle')"
|
||||||
|
[description]="this.i18nService.t('noReportRunDescription')"
|
||||||
|
[benefits]="emptyStateBenefits"
|
||||||
|
[buttonText]="this.i18nService.t('riskInsightsRunReport')"
|
||||||
|
[buttonIcon]=""
|
||||||
|
[buttonAction]="this.generateReport.bind(this)"
|
||||||
|
></empty-state-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Show screen when there is report data -->
|
||||||
|
<div class="tw-min-h-screen tw-flex tw-flex-col">
|
||||||
|
<div>
|
||||||
|
<h1 bitTypography="h1">{{ "riskInsights" | i18n }}</h1>
|
||||||
|
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
|
||||||
|
{{ "reviewAtRiskPasswords" | i18n }}
|
||||||
|
</div>
|
||||||
|
@if (dataLastUpdated) {
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
<span class="tw-mx-4">{{
|
||||||
|
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
|
||||||
|
}}</span>
|
||||||
|
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
||||||
|
<span class="tw-flex tw-justify-center">
|
||||||
|
<button
|
||||||
|
*ngIf="!isRunningReport"
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
|
||||||
|
tabindex="0"
|
||||||
|
[bitAction]="generateReport.bind(this)"
|
||||||
|
>
|
||||||
|
{{ "riskInsightsRunReport" | i18n }}
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<i
|
||||||
|
*ngIf="isRunningReport"
|
||||||
|
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
<div class="tw-flex-1 tw-flex tw-flex-col">
|
||||||
</div>
|
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
||||||
|
@if (isRiskInsightsActivityTabFeatureEnabled) {
|
||||||
|
<bit-tab label="{{ 'activity' | i18n }}">
|
||||||
|
<dirt-all-activity></dirt-all-activity>
|
||||||
|
</bit-tab>
|
||||||
|
}
|
||||||
|
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
||||||
|
<dirt-all-applications></dirt-all-applications>
|
||||||
|
</bit-tab>
|
||||||
|
<bit-tab>
|
||||||
|
<ng-template bitTabLabel>
|
||||||
|
<i class="bwi bwi-star"></i>
|
||||||
|
{{
|
||||||
|
"criticalApplicationsWithCount"
|
||||||
|
| i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0
|
||||||
|
}}
|
||||||
|
</ng-template>
|
||||||
|
<dirt-critical-applications></dirt-critical-applications>
|
||||||
|
</bit-tab>
|
||||||
|
</bit-tab-group>
|
||||||
|
</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()">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { map, tap } from "rxjs/operators";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import {
|
import {
|
||||||
DrawerType,
|
DrawerType,
|
||||||
|
ReportStatus,
|
||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} 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";
|
||||||
@@ -29,6 +30,7 @@ import { AllApplicationsComponent } from "./all-applications/all-applications.co
|
|||||||
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||||
import { EmptyStateCardComponent } from "./empty-state-card.component";
|
import { EmptyStateCardComponent } from "./empty-state-card.component";
|
||||||
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
||||||
|
import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||||
@@ -48,41 +50,35 @@ import { RiskInsightsTabType } from "./models/risk-insights.models";
|
|||||||
DrawerBodyComponent,
|
DrawerBodyComponent,
|
||||||
DrawerHeaderComponent,
|
DrawerHeaderComponent,
|
||||||
AllActivityComponent,
|
AllActivityComponent,
|
||||||
|
ApplicationsLoadingComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RiskInsightsComponent implements OnInit, OnDestroy {
|
export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private _isDrawerOpen: boolean = false;
|
private _isDrawerOpen: boolean = false;
|
||||||
|
protected ReportStatusEnum = ReportStatus;
|
||||||
|
|
||||||
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
|
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
|
||||||
isRiskInsightsActivityTabFeatureEnabled: boolean = false;
|
isRiskInsightsActivityTabFeatureEnabled: boolean = false;
|
||||||
|
|
||||||
appsCount: number = 0;
|
appsCount: number = 0;
|
||||||
// Leaving this commented because it's not used but seems important
|
|
||||||
// notifiedMembersCount: number = 0;
|
|
||||||
|
|
||||||
private organizationId: OrganizationId = "" as OrganizationId;
|
private organizationId: OrganizationId = "" as OrganizationId;
|
||||||
|
|
||||||
dataLastUpdated: Date | null = null;
|
dataLastUpdated: Date | null = null;
|
||||||
refetching: boolean = false;
|
|
||||||
|
|
||||||
// Empty state properties
|
// Empty state properties
|
||||||
protected hasReportBeenRun = false;
|
protected organizationName = "";
|
||||||
protected reportHasLoaded = false;
|
|
||||||
protected hasVaultItems = false;
|
|
||||||
private organizationName = "";
|
|
||||||
|
|
||||||
// Empty state computed properties
|
// Empty state computed properties
|
||||||
protected shouldShowImportDataState = false;
|
protected emptyStateBenefits: [string, string][] = [
|
||||||
protected emptyStateTitle = "";
|
[this.i18nService.t("benefit1Title"), this.i18nService.t("benefit1Description")],
|
||||||
protected emptyStateDescription = "";
|
[this.i18nService.t("benefit2Title"), this.i18nService.t("benefit2Description")],
|
||||||
protected emptyStateBenefits: [string, string][] = [];
|
[this.i18nService.t("benefit3Title"), this.i18nService.t("benefit3Description")],
|
||||||
protected emptyStateButtonText = "";
|
];
|
||||||
protected emptyStateButtonIcon = "";
|
|
||||||
protected emptyStateButtonAction: (() => void) | null = null;
|
|
||||||
protected emptyStateVideoSrc: string | null = "/videos/risk-insights-mark-as-critical.mp4";
|
protected emptyStateVideoSrc: string | null = "/videos/risk-insights-mark-as-critical.mp4";
|
||||||
|
|
||||||
private static readonly IMPORT_ICON = "bwi bwi-download";
|
protected IMPORT_ICON = "bwi bwi-download";
|
||||||
|
|
||||||
// TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235
|
// TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235
|
||||||
|
|
||||||
@@ -91,7 +87,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
protected dataService: RiskInsightsDataService,
|
protected dataService: RiskInsightsDataService,
|
||||||
private i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
) {
|
) {
|
||||||
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).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;
|
||||||
@@ -125,28 +121,15 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Combine report data, vault items check, organization details, and generation state
|
// Combine report data, vault items check, organization details, and generation state
|
||||||
// This declarative pattern ensures proper cleanup and prevents memory leaks
|
// This declarative pattern ensures proper cleanup and prevents memory leaks
|
||||||
combineLatest([
|
combineLatest([this.dataService.enrichedReportData$, this.dataService.organizationDetails$])
|
||||||
this.dataService.enrichedReportData$,
|
|
||||||
this.dataService.hasVaultItems$,
|
|
||||||
this.dataService.organizationDetails$,
|
|
||||||
this.dataService.isGeneratingReport$,
|
|
||||||
])
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(([report, hasVaultItems, orgDetails, isGenerating]) => {
|
.subscribe(([report, orgDetails]) => {
|
||||||
// Update report state
|
// 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
|
// Update organization name
|
||||||
this.organizationName = orgDetails?.organizationName ?? "";
|
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
|
||||||
@@ -171,10 +154,6 @@ 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,
|
||||||
@@ -228,37 +207,4 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
"import",
|
"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