-
-
-
-
- @if (showProgress()) {
-
-
-
-
- {{ progressMessage() || "Processing..." }}
- {{ getOverallProgress() | number: "1.0-0" }}%
-
-
-
-
-
- @if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
-
-
-
- {{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
- {{ hibpProgress().total }}
-
- {{ hibpProgress().percent }}%
-
-
-
- }
-
-
- @if (processingPhase() === ProcessingPhase.Complete) {
-
-
- {{ "reportComplete" | i18n }}
-
- }
-
-
- @if (processingPhase() === ProcessingPhase.Error && error()) {
-
-
- {{ error() }}
-
- }
-
- }
-
@if (items().length > 0) {
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts
index e2de535d757..072e11858a4 100644
--- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts
+++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts
@@ -1,104 +1,51 @@
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
-import {
- ChangeDetectionStrategy,
- Component,
- DestroyRef,
- effect,
- inject,
- OnInit,
- signal,
-} from "@angular/core";
-import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
-import { ActivatedRoute } from "@angular/router";
+import { ChangeDetectionStrategy, Component, effect, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import {
- CipherAccessMappingService,
- PasswordHealthService,
- RiskInsightsPrototypeOrchestrationService,
- RiskInsightsPrototypeService,
-} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
+import { RiskInsightsPrototypeOrchestrationService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import {
ProcessingPhase,
RiskInsightsItem,
RiskInsightsItemStatus,
} from "@bitwarden/common/dirt/reports/risk-insights";
-import { OrganizationId } from "@bitwarden/common/types/guid";
-import {
- BadgeModule,
- ButtonModule,
- CheckboxModule,
- ProgressModule,
- TableDataSource,
- TableModule,
-} from "@bitwarden/components";
-
+import { BadgeModule, TableDataSource, TableModule } from "@bitwarden/components";
/* eslint-enable no-restricted-imports */
/**
* Items tab component for the Risk Insights Prototype.
*
* Displays a table of cipher items with health status and member counts.
- * Features:
- * - Progressive loading with status indicators
- * - Virtual scrolling table for large datasets
- * - Configurable health checks (weak, reused, exposed passwords)
+ * The orchestrator is provided by the parent component and shared across tabs.
*/
@Component({
selector: "app-risk-insights-prototype-items",
templateUrl: "./risk-insights-prototype-items.component.html",
standalone: true,
- imports: [
- CommonModule,
- JslibModule,
- TableModule,
- ProgressModule,
- CheckboxModule,
- ButtonModule,
- BadgeModule,
- ],
- providers: [
- RiskInsightsPrototypeOrchestrationService,
- RiskInsightsPrototypeService,
- CipherAccessMappingService,
- PasswordHealthService,
- ],
+ imports: [CommonModule, JslibModule, TableModule, BadgeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class RiskInsightsPrototypeItemsComponent implements OnInit {
+export class RiskInsightsPrototypeItemsComponent {
// ============================================================================
// Injected Dependencies
// ============================================================================
- private readonly route = inject(ActivatedRoute);
- private readonly destroyRef = inject(DestroyRef);
private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
// ============================================================================
// Expose Orchestrator Signals to Template
// ============================================================================
- // Configuration flags
+ // Configuration flags (for conditional rendering in template)
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
readonly enableHibp = this.orchestrator.enableHibp;
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
// Processing state
readonly processingPhase = this.orchestrator.processingPhase;
- readonly progressMessage = this.orchestrator.progressMessage;
-
- // Progress tracking
- readonly cipherProgress = this.orchestrator.cipherProgress;
- readonly healthProgress = this.orchestrator.healthProgress;
- readonly memberProgress = this.orchestrator.memberProgress;
- readonly hibpProgress = this.orchestrator.hibpProgress;
// Results
readonly items = this.orchestrator.items;
- // Error state
- readonly error = this.orchestrator.error;
-
// Expose constants for template access
readonly ProcessingPhase = ProcessingPhase;
readonly RiskInsightsItemStatus = RiskInsightsItemStatus;
@@ -113,9 +60,6 @@ export class RiskInsightsPrototypeItemsComponent implements OnInit {
/** Row size for virtual scrolling (in pixels) */
protected readonly ROW_SIZE = 52;
- /** Whether the component has been initialized */
- protected readonly initialized = signal(false);
-
// ============================================================================
// Lifecycle
// ============================================================================
@@ -128,82 +72,6 @@ export class RiskInsightsPrototypeItemsComponent implements OnInit {
});
}
- ngOnInit(): void {
- // Get organization ID from route and initialize orchestrator
- this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
- const organizationId = params["organizationId"] as OrganizationId;
- if (organizationId) {
- this.orchestrator.initializeForOrganization(organizationId);
- this.initialized.set(true);
- }
- });
- }
-
- // ============================================================================
- // UI Actions
- // ============================================================================
-
- /** Start processing - run the report */
- protected runReport(): void {
- this.orchestrator.startProcessing();
- }
-
- /** Toggle weak password check */
- protected toggleWeakPassword(): void {
- this.orchestrator.toggleEnableWeakPassword();
- }
-
- /** Toggle HIBP check */
- protected toggleHibp(): void {
- this.orchestrator.toggleEnableHibp();
- }
-
- /** Toggle reused password check */
- protected toggleReusedPassword(): void {
- this.orchestrator.toggleEnableReusedPassword();
- }
-
- // ============================================================================
- // Computed Properties
- // ============================================================================
-
- /** Check if processing is currently running */
- protected isProcessing(): boolean {
- const phase = this.processingPhase();
- return (
- phase !== ProcessingPhase.Idle &&
- phase !== ProcessingPhase.Complete &&
- phase !== ProcessingPhase.Error
- );
- }
-
- /** Check if progress section should be shown */
- protected showProgress(): boolean {
- return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
- }
-
- /** Calculate overall progress percentage */
- protected getOverallProgress(): number {
- const phase = this.processingPhase();
-
- switch (phase) {
- case ProcessingPhase.Idle:
- return 0;
- case ProcessingPhase.LoadingCiphers:
- return this.cipherProgress().percent * 0.2; // 0-20%
- case ProcessingPhase.RunningHealthChecks:
- return 20 + this.healthProgress().percent * 0.2; // 20-40%
- case ProcessingPhase.LoadingMembers:
- return 40 + this.memberProgress().percent * 0.4; // 40-80%
- case ProcessingPhase.RunningHibp:
- return 80 + this.hibpProgress().percent * 0.2; // 80-100%
- case ProcessingPhase.Complete:
- return 100;
- default:
- return 0;
- }
- }
-
// ============================================================================
// TrackBy Functions
// ============================================================================
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html
index 03429ee2fac..b9b543279b1 100644
--- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html
+++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html
@@ -7,6 +7,108 @@
+
+
+
+
+ @if (showProgress()) {
+
+
+
+
+ {{ progressMessage() || "Processing..." }}
+ {{ getOverallProgress() | number: "1.0-0" }}%
+
+
+
+
+
+ @if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
+
+
+
+ {{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
+ {{ hibpProgress().total }}
+
+ {{ hibpProgress().percent }}%
+
+
+
+ }
+
+
+ @if (processingPhase() === ProcessingPhase.Complete) {
+
+
+ {{ "reportComplete" | i18n }}
+
+ }
+
+
+ @if (processingPhase() === ProcessingPhase.Error && error()) {
+
+
+ {{ error() }}
+
+ }
+
+ }
+
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts
index cda4477631b..c0d3e7d31dc 100644
--- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts
+++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts
@@ -1,15 +1,32 @@
+/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
-import { Component, ChangeDetectionStrategy, DestroyRef, inject } from "@angular/core";
+import {
+ Component,
+ ChangeDetectionStrategy,
+ DestroyRef,
+ inject,
+ OnInit,
+ signal,
+} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { TabsModule } from "@bitwarden/components";
+import {
+ CipherAccessMappingService,
+ PasswordHealthService,
+ RiskInsightsPrototypeOrchestrationService,
+ RiskInsightsPrototypeService,
+} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
+import { ProcessingPhase } from "@bitwarden/common/dirt/reports/risk-insights";
+import { OrganizationId } from "@bitwarden/common/types/guid";
+import { ButtonModule, CheckboxModule, ProgressModule, TabsModule } from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { RiskInsightsPrototypeApplicationsComponent } from "./applications/risk-insights-prototype-applications.component";
import { RiskInsightsPrototypeItemsComponent } from "./items/risk-insights-prototype-items.component";
import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-prototype-members.component";
+/* eslint-enable no-restricted-imports */
@Component({
selector: "app-risk-insights-prototype",
@@ -20,26 +37,64 @@ import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-p
JslibModule,
TabsModule,
HeaderModule,
+ ButtonModule,
+ CheckboxModule,
+ ProgressModule,
RiskInsightsPrototypeItemsComponent,
RiskInsightsPrototypeApplicationsComponent,
RiskInsightsPrototypeMembersComponent,
],
+ providers: [
+ RiskInsightsPrototypeOrchestrationService,
+ RiskInsightsPrototypeService,
+ CipherAccessMappingService,
+ PasswordHealthService,
+ ],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class RiskInsightsPrototypeComponent {
+export class RiskInsightsPrototypeComponent implements OnInit {
private destroyRef = inject(DestroyRef);
+ private route = inject(ActivatedRoute);
+ private router = inject(Router);
+ protected readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
tabIndex = 0;
- constructor(
- private route: ActivatedRoute,
- private router: Router,
- ) {
+ // Expose orchestrator signals to template
+ readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
+ readonly enableHibp = this.orchestrator.enableHibp;
+ readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
+ readonly processingPhase = this.orchestrator.processingPhase;
+ readonly progressMessage = this.orchestrator.progressMessage;
+ readonly cipherProgress = this.orchestrator.cipherProgress;
+ readonly healthProgress = this.orchestrator.healthProgress;
+ readonly memberProgress = this.orchestrator.memberProgress;
+ readonly hibpProgress = this.orchestrator.hibpProgress;
+ readonly error = this.orchestrator.error;
+
+ // Expose constants for template access
+ readonly ProcessingPhase = ProcessingPhase;
+
+ // Component initialization state
+ protected readonly initialized = signal(false);
+
+ constructor() {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : 0;
});
}
+ ngOnInit(): void {
+ // Get organization ID from route and initialize orchestrator
+ this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+ const organizationId = params["organizationId"] as OrganizationId;
+ if (organizationId) {
+ this.orchestrator.initializeForOrganization(organizationId);
+ this.initialized.set(true);
+ }
+ });
+ }
+
async onTabChange(newIndex: number): Promise {
await this.router.navigate([], {
relativeTo: this.route,
@@ -47,4 +102,69 @@ export class RiskInsightsPrototypeComponent {
queryParamsHandling: "merge",
});
}
+
+ // ============================================================================
+ // UI Actions
+ // ============================================================================
+
+ /** Start processing - run the report */
+ protected runReport(): void {
+ this.orchestrator.startProcessing();
+ }
+
+ /** Toggle weak password check */
+ protected toggleWeakPassword(): void {
+ this.orchestrator.toggleEnableWeakPassword();
+ }
+
+ /** Toggle HIBP check */
+ protected toggleHibp(): void {
+ this.orchestrator.toggleEnableHibp();
+ }
+
+ /** Toggle reused password check */
+ protected toggleReusedPassword(): void {
+ this.orchestrator.toggleEnableReusedPassword();
+ }
+
+ // ============================================================================
+ // Computed Properties
+ // ============================================================================
+
+ /** Check if processing is currently running */
+ protected isProcessing(): boolean {
+ const phase = this.processingPhase();
+ return (
+ phase !== ProcessingPhase.Idle &&
+ phase !== ProcessingPhase.Complete &&
+ phase !== ProcessingPhase.Error
+ );
+ }
+
+ /** Check if progress section should be shown */
+ protected showProgress(): boolean {
+ return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
+ }
+
+ /** Calculate overall progress percentage */
+ protected getOverallProgress(): number {
+ const phase = this.processingPhase();
+
+ switch (phase) {
+ case ProcessingPhase.Idle:
+ return 0;
+ case ProcessingPhase.LoadingCiphers:
+ return this.cipherProgress().percent * 0.2; // 0-20%
+ case ProcessingPhase.RunningHealthChecks:
+ return 20 + this.healthProgress().percent * 0.2; // 20-40%
+ case ProcessingPhase.LoadingMembers:
+ return 40 + this.memberProgress().percent * 0.4; // 40-80%
+ case ProcessingPhase.RunningHibp:
+ return 80 + this.hibpProgress().percent * 0.2; // 80-100%
+ case ProcessingPhase.Complete:
+ return 100;
+ default:
+ return 0;
+ }
+ }
}
diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts
index b26c228751c..6f3b8e09032 100644
--- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts
+++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts
@@ -8,12 +8,19 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
-import { CipherAccessMappingService, MemberAccessLoadState } from "./cipher-access-mapping.service";
+import { getTrimmedCipherUris } from "../../helpers/risk-insights-data-mappers";
+
+import {
+ CipherAccessMappingService,
+ CipherWithMemberAccess,
+ MemberAccessLoadState,
+} from "./cipher-access-mapping.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsPrototypeService } from "./risk-insights-prototype.service";
import {
ProcessingPhase,
ProgressInfo,
+ RiskInsightsApplication,
RiskInsightsItem,
RiskInsightsItemStatus,
calculateRiskStatus,
@@ -51,6 +58,15 @@ export class RiskInsightsPrototypeOrchestrationService {
private allCiphers: CipherView[] = [];
private passwordUseMap: Map = new Map();
+ /** Maps cipher ID to the domains (applications) it belongs to */
+ private cipherToApplicationsMap = new Map();
+
+ /** Maps domain to the cipher IDs that belong to it */
+ private applicationToCiphersMap = new Map();
+
+ /** Maps cipher ID to the set of member IDs with access (for at-risk member tracking) */
+ private cipherToMemberIdsMap = new Map>();
+
// ============================================================================
// Internal Signals (private, writable)
// ============================================================================
@@ -72,6 +88,7 @@ export class RiskInsightsPrototypeOrchestrationService {
// Results
private readonly _items = signal([]);
+ private readonly _applications = signal([]);
// Error state
private readonly _error = signal(null);
@@ -97,6 +114,7 @@ export class RiskInsightsPrototypeOrchestrationService {
// Results
readonly items = this._items.asReadonly();
+ readonly applications = this._applications.asReadonly();
// Error state
readonly error = this._error.asReadonly();
@@ -197,6 +215,9 @@ export class RiskInsightsPrototypeOrchestrationService {
// Build password use map for reuse detection
this.passwordUseMap = this.riskInsightsService.buildPasswordUseMap(ciphers);
+
+ // Build application aggregations
+ this.buildApplicationAggregations();
}),
// PHASE 2: Run health checks if enabled
switchMap(() => this.runHealthChecksIfEnabled$()),
@@ -240,6 +261,7 @@ export class RiskInsightsPrototypeOrchestrationService {
*/
resetState(): void {
this._items.set([]);
+ this._applications.set([]);
this._processingPhase.set(ProcessingPhase.Idle);
this._progressMessage.set("");
this._cipherProgress.set({ current: 0, total: 0, percent: 0 });
@@ -250,6 +272,9 @@ export class RiskInsightsPrototypeOrchestrationService {
this.cipherIndexMap.clear();
this.allCiphers = [];
this.passwordUseMap.clear();
+ this.cipherToApplicationsMap.clear();
+ this.applicationToCiphersMap.clear();
+ this.cipherToMemberIdsMap.clear();
}
// ============================================================================
@@ -322,6 +347,9 @@ export class RiskInsightsPrototypeOrchestrationService {
total: totalCiphers,
percent: Math.round((processedCount / totalCiphers) * 100),
});
+
+ // Update application at-risk counts after health checks
+ this.updateApplicationAtRiskCounts();
}),
map((): void => undefined),
);
@@ -370,6 +398,9 @@ export class RiskInsightsPrototypeOrchestrationService {
// Update items with member counts
this.updateItemsWithMemberCounts(progressResult.processedCiphers);
+
+ // Update application member counts
+ this.updateApplicationsWithMemberCounts(progressResult.processedCiphers);
}
}),
last(),
@@ -485,6 +516,9 @@ export class RiskInsightsPrototypeOrchestrationService {
if (hasChanges) {
this._items.set(currentItems);
+
+ // Update application at-risk counts after HIBP updates
+ this.updateApplicationAtRiskCounts();
}
}
@@ -542,4 +576,162 @@ export class RiskInsightsPrototypeOrchestrationService {
this._items.set(currentItems);
}
}
+
+ // ============================================================================
+ // Private Methods - Application Aggregation
+ // ============================================================================
+
+ /**
+ * Builds application aggregations from the loaded ciphers.
+ * Called after Phase 1 cipher loading completes.
+ * Creates an application entry for each unique domain found across all ciphers.
+ */
+ private buildApplicationAggregations(): void {
+ const applicationMap = new Map();
+
+ // Clear existing maps
+ this.cipherToApplicationsMap.clear();
+ this.applicationToCiphersMap.clear();
+
+ for (const cipher of this.allCiphers) {
+ const domains = getTrimmedCipherUris(cipher);
+
+ // Track which applications this cipher belongs to
+ this.cipherToApplicationsMap.set(cipher.id, domains);
+
+ for (const domain of domains) {
+ // Track which ciphers belong to each application
+ if (!this.applicationToCiphersMap.has(domain)) {
+ this.applicationToCiphersMap.set(domain, []);
+ }
+ this.applicationToCiphersMap.get(domain)!.push(cipher.id);
+
+ // Create or update application entry
+ if (!applicationMap.has(domain)) {
+ applicationMap.set(domain, {
+ domain,
+ passwordCount: 0,
+ atRiskPasswordCount: 0,
+ memberIds: new Set(),
+ atRiskMemberIds: new Set(),
+ memberAccessPending: true,
+ cipherIds: [],
+ });
+ }
+
+ const app = applicationMap.get(domain)!;
+ app.passwordCount++;
+ app.cipherIds.push(cipher.id);
+ }
+ }
+
+ // Convert to array and sort by password count descending
+ const applications = Array.from(applicationMap.values()).sort(
+ (a, b) => b.passwordCount - a.passwordCount,
+ );
+
+ this._applications.set(applications);
+ }
+
+ /**
+ * Updates application member counts when cipher member data arrives.
+ * Called incrementally during Phase 3 member loading.
+ *
+ * @param processedCiphers - Ciphers with their member access data
+ */
+ private updateApplicationsWithMemberCounts(processedCiphers: CipherWithMemberAccess[]): void {
+ const currentApplications = this._applications();
+ const items = this._items();
+ const itemMap = new Map(items.map((item) => [item.cipherId, item]));
+
+ // Build a domain -> Set map
+ const domainMemberMap = new Map>();
+ const domainAtRiskMemberMap = new Map>();
+
+ for (const processed of processedCiphers) {
+ const cipherId = processed.cipher.id;
+ const domains = this.cipherToApplicationsMap.get(cipherId) ?? [];
+ const memberIds = processed.members.map((m) => m.userId);
+
+ // Store cipher member IDs for at-risk member tracking
+ this.cipherToMemberIdsMap.set(cipherId, new Set(memberIds));
+
+ // Check if this cipher is at-risk
+ const item = itemMap.get(cipherId);
+ const isAtRisk = item?.status === RiskInsightsItemStatus.AtRisk;
+
+ for (const domain of domains) {
+ // Add all members to domain's member set
+ if (!domainMemberMap.has(domain)) {
+ domainMemberMap.set(domain, new Set());
+ }
+ for (const memberId of memberIds) {
+ domainMemberMap.get(domain)!.add(memberId);
+ }
+
+ // If cipher is at-risk, add members to at-risk set
+ if (isAtRisk) {
+ if (!domainAtRiskMemberMap.has(domain)) {
+ domainAtRiskMemberMap.set(domain, new Set());
+ }
+ for (const memberId of memberIds) {
+ domainAtRiskMemberMap.get(domain)!.add(memberId);
+ }
+ }
+ }
+ }
+
+ // Update applications with member data
+ const updatedApplications: RiskInsightsApplication[] = currentApplications.map((app) => {
+ const memberIds = domainMemberMap.get(app.domain) ?? app.memberIds;
+ const atRiskMemberIds = domainAtRiskMemberMap.get(app.domain) ?? app.atRiskMemberIds;
+
+ return {
+ ...app,
+ memberIds: new Set(memberIds),
+ atRiskMemberIds: new Set(atRiskMemberIds),
+ memberAccessPending: false,
+ };
+ });
+
+ this._applications.set(updatedApplications);
+ }
+
+ /**
+ * Updates application at-risk counts based on current item statuses.
+ * Called after health checks complete or when item statuses change.
+ */
+ private updateApplicationAtRiskCounts(): void {
+ const items = this._items();
+ const itemMap = new Map(items.map((item) => [item.cipherId, item]));
+ const currentApplications = this._applications();
+
+ const updatedApplications = currentApplications.map((app) => {
+ let atRiskCount = 0;
+ const atRiskMemberIds = new Set();
+
+ for (const cipherId of app.cipherIds) {
+ const item = itemMap.get(cipherId);
+ if (item?.status === RiskInsightsItemStatus.AtRisk) {
+ atRiskCount++;
+
+ // Add members of at-risk ciphers to at-risk member set
+ const cipherMemberIds = this.cipherToMemberIdsMap.get(cipherId);
+ if (cipherMemberIds) {
+ for (const memberId of cipherMemberIds) {
+ atRiskMemberIds.add(memberId);
+ }
+ }
+ }
+ }
+
+ return {
+ ...app,
+ atRiskPasswordCount: atRiskCount,
+ atRiskMemberIds,
+ };
+ });
+
+ this._applications.set(updatedApplications);
+ }
}
diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts
index d3f1ab366fa..25942e11ffc 100644
--- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts
+++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts
@@ -7,6 +7,7 @@ export {
ProcessingPhase,
ProgressInfo,
RiskInsightsItem,
+ RiskInsightsApplication,
createRiskInsightsItem,
calculateRiskStatus,
} from "@bitwarden/common/dirt/reports/risk-insights/types";
diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts
index 4fddb5a2011..41e8ea5188b 100644
--- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts
+++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts
@@ -3,7 +3,6 @@ export * from "./api/member-cipher-details-api.service";
export * from "./api/risk-insights-api.service";
export * from "./api/security-tasks-api.service";
export * from "./domain/cipher-access-mapping.service";
-export * from "./domain/cipher-health-orchestration.service";
export * from "./domain/critical-apps.service";
export * from "./domain/password-health.service";
export * from "./domain/risk-insights-encryption.service";
diff --git a/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts
index ef002edc538..d6f26e883ca 100644
--- a/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts
+++ b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts
@@ -1,6 +1,6 @@
import { OrganizationId } from "@bitwarden/common/types/guid";
-import { ProcessingPhase, ProgressInfo, RiskInsightsItem } from "../types";
+import { ProcessingPhase, ProgressInfo, RiskInsightsApplication, RiskInsightsItem } from "../types";
/**
* Generic read-only signal interface for framework-agnostic abstraction.
@@ -35,8 +35,9 @@ export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction {
abstract readonly memberProgress: ReadonlySignal;
abstract readonly hibpProgress: ReadonlySignal;
- // Results (read-only signal)
+ // Results (read-only signals)
abstract readonly items: ReadonlySignal;
+ abstract readonly applications: ReadonlySignal;
// Error state (read-only signal)
abstract readonly error: ReadonlySignal;
diff --git a/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts
index 5813239a438..03edaa0f589 100644
--- a/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts
+++ b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts
@@ -36,6 +36,26 @@ export interface ProgressInfo {
percent: number;
}
+/**
+ * Represents an application (domain) aggregation in the Applications tab
+ */
+export interface RiskInsightsApplication {
+ /** The trimmed domain (e.g., "amazon.com") */
+ domain: string;
+ /** Number of ciphers associated with this application */
+ passwordCount: number;
+ /** Number of at-risk ciphers */
+ atRiskPasswordCount: number;
+ /** Set of distinct member IDs with access to ANY cipher in this application */
+ memberIds: Set;
+ /** Set of distinct member IDs with access to AT-RISK ciphers */
+ atRiskMemberIds: Set;
+ /** Whether member data is still being loaded */
+ memberAccessPending: boolean;
+ /** IDs of ciphers belonging to this application (for expandable row drill-down) */
+ cipherIds: string[];
+}
+
/**
* Represents a single item in the risk insights report
*/