From 03fdae924d8f8ea644ace29b14db39e1f989a95b Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 20 Jan 2026 15:16:28 -0500 Subject: [PATCH] members tab and refactoring --- ...sights-prototype-applications.component.ts | 21 +- ...-insights-prototype-members.component.html | 185 ++++++++++++++++- ...sk-insights-prototype-members.component.ts | 152 +++++++++++++- .../risk-insights-prototype.component.html | 12 +- ...nsights-prototype-orchestration.service.ts | 193 +++++++++++++++++- .../domain/risk-insights-prototype.types.ts | 1 + ...otype-orchestration.service.abstraction.ts | 15 +- .../types/risk-insights-prototype.types.ts | 20 ++ 8 files changed, 571 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts index 3339e777f7e..f67e641cb34 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts @@ -1,6 +1,13 @@ /* eslint-disable no-restricted-imports -- Prototype feature using licensed services */ import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, effect, inject, signal } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { RiskInsightsPrototypeOrchestrationService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; @@ -68,6 +75,12 @@ export class RiskInsightsPrototypeApplicationsComponent { /** Set of expanded application domains */ protected readonly expandedApplications = signal(new Set()); + /** Cached map of cipher ID to item for O(1) lookups */ + private readonly itemMap = computed(() => { + const items = this.items(); + return new Map(items.map((item) => [item.cipherId, item])); + }); + // ============================================================================ // Lifecycle // ============================================================================ @@ -104,11 +117,9 @@ export class RiskInsightsPrototypeApplicationsComponent { /** Get cipher items for an application (for expanded view) */ protected getCiphersForApplication(cipherIds: string[]): RiskInsightsItem[] { - const allItems = this.items(); - const itemMap = new Map(allItems.map((item) => [item.cipherId, item])); - + const map = this.itemMap(); return cipherIds - .map((id) => itemMap.get(id)) + .map((id) => map.get(id)) .filter((item): item is RiskInsightsItem => item !== undefined); } diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html index f60756ac55f..93cc34613b0 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html @@ -1,6 +1,181 @@ -
-

{{ "members" | i18n }}

-

- {{ "riskInsightsPrototypeMembersPlaceholder" | i18n }} -

+
+ + @if (isMemberDataLoading() && members().length === 0) { +
+ +

+ {{ "loading" | i18n }} + @if (memberProgress().total > 0) { + ({{ memberProgress().current | number }}/{{ memberProgress().total | number }}) + } +

+
+ } + + + @if (members().length > 0) { +
+ {{ "totalMembers" | i18n }}: {{ members().length | number }} +
+ + + + + + + + + + + + @for (member of members(); track member.memberId) { + + + + + + + + + + + + + + + + + @if (isExpanded(member.memberId)) { + @for (cipher of getCiphersForMember(member.cipherIds); track cipher.cipherId) { + + + + + + + + + + + + + + } + } + } + +
{{ "email" | i18n }}{{ "passwords" | i18n }}{{ "atRiskPasswords" | i18n }}
+ + +
{{ member.email }}
+
+ @if (member.memberAccessPending) { + + } @else { + {{ member.cipherCount | number }} + } + + @if (member.memberAccessPending) { + + } @else if (member.atRiskCipherCount > 0) { + {{ member.atRiskCipherCount | number }} + } @else { + 0 + } +
+
+
+ {{ cipher.cipherName }} +
+ @if (cipher.cipherSubtitle) { +
+ {{ cipher.cipherSubtitle }} +
+ } +
+
+
+ + @if (cipher.weakPassword === true) { + W + } + + @if (cipher.reusedPassword === true) { + R + } + + @if (cipher.exposedPassword === true) { + E + } + + @if ( + cipher.status === RiskInsightsItemStatus.Healthy && + !cipher.weakPassword && + !cipher.reusedPassword && + !cipher.exposedPassword + ) { + + } + + @if (cipher.status === null) { + + } +
+
+ @if (cipher.status === null) { + + } @else if (cipher.status === RiskInsightsItemStatus.AtRisk) { + {{ "atRisk" | i18n }} + } @else { + {{ "healthy" | i18n }} + } +
+ } @else if (processingPhase() === ProcessingPhase.Idle) { + +
+ +

{{ "riskInsightsPrototypeMembersPlaceholder" | i18n }}

+
+ } @else if ( + !isMemberDataLoading() && members().length === 0 && processingPhase() !== ProcessingPhase.Idle + ) { + +
+ +

{{ "noMembersInList" | i18n }}

+
+ }
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts index 80bf6943ee7..d1223890e8a 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts @@ -1,13 +1,159 @@ +/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */ import { CommonModule } from "@angular/common"; -import { Component, ChangeDetectionStrategy } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { RiskInsightsPrototypeOrchestrationService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; +import { + ProcessingPhase, + RiskInsightsItem, + RiskInsightsItemStatus, + RiskInsightsMember, +} from "@bitwarden/common/dirt/reports/risk-insights"; +import { BadgeModule, TableDataSource, TableModule } from "@bitwarden/components"; +/* eslint-enable no-restricted-imports */ +/** + * Members tab component for the Risk Insights Prototype. + * + * Displays a table of organization members with aggregated cipher data. + * Features: + * - Expandable rows to show ciphers each member has access to + * - Virtual scrolling table for large datasets + * - At-risk cipher counts per member + */ @Component({ selector: "app-risk-insights-prototype-members", templateUrl: "./risk-insights-prototype-members.component.html", standalone: true, - imports: [CommonModule, JslibModule], + imports: [CommonModule, JslibModule, TableModule, BadgeModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RiskInsightsPrototypeMembersComponent {} +export class RiskInsightsPrototypeMembersComponent { + // ============================================================================ + // Injected Dependencies + // ============================================================================ + private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService); + + // ============================================================================ + // Expose Orchestrator Signals to Template + // ============================================================================ + + // Processing state + readonly processingPhase = this.orchestrator.processingPhase; + readonly memberProgress = this.orchestrator.memberProgress; + + // Results + readonly members = this.orchestrator.members; + readonly items = this.orchestrator.items; + + // Expose constants for template access + readonly ProcessingPhase = ProcessingPhase; + readonly RiskInsightsItemStatus = RiskInsightsItemStatus; + + // ============================================================================ + // Component State + // ============================================================================ + + /** Table data source for virtual scrolling */ + protected readonly dataSource = new TableDataSource(); + + /** Row size for virtual scrolling (in pixels) */ + protected readonly ROW_SIZE = 52; + + /** Set of expanded member IDs */ + protected readonly expandedMembers = signal(new Set()); + + /** Cached item map for O(1) cipher lookups - rebuilt only when items() changes */ + private readonly itemMap = computed(() => { + const items = this.items(); + return new Map(items.map((item) => [item.cipherId, item])); + }); + + /** Whether we've requested member data to be built (lazy loading trigger) */ + private hasRequestedMemberData = false; + + // ============================================================================ + // Lifecycle + // ============================================================================ + + constructor() { + // Effect to sync members signal to table data source + effect(() => { + const members = this.members(); + this.dataSource.data = members; + }); + + // Effect to trigger lazy loading of member aggregations when phase is ready + effect(() => { + const phase = this.processingPhase(); + const isReady = phase === ProcessingPhase.Complete || phase === ProcessingPhase.RunningHibp; + + if (isReady && !this.hasRequestedMemberData) { + this.hasRequestedMemberData = true; + this.orchestrator.ensureMemberAggregationsBuilt(); + } + }); + } + + // ============================================================================ + // Expansion Methods + // ============================================================================ + + /** Toggle expansion state for a member */ + protected toggleExpanded(memberId: string): void { + this.expandedMembers.update((current) => { + const newSet = new Set(current); + if (newSet.has(memberId)) { + newSet.delete(memberId); + } else { + newSet.add(memberId); + } + return newSet; + }); + } + + /** Check if a member is expanded */ + protected isExpanded(memberId: string): boolean { + return this.expandedMembers().has(memberId); + } + + /** Get cipher items for a member (for expanded view) - uses cached itemMap for performance */ + protected getCiphersForMember(cipherIds: string[]): RiskInsightsItem[] { + const map = this.itemMap(); + return cipherIds + .map((id) => map.get(id)) + .filter((item): item is RiskInsightsItem => item !== undefined); + } + + /** Check if member data is still loading */ + protected isMemberDataLoading(): boolean { + const phase = this.processingPhase(); + return ( + phase === ProcessingPhase.LoadingCiphers || + phase === ProcessingPhase.RunningHealthChecks || + phase === ProcessingPhase.LoadingMembers + ); + } + + // ============================================================================ + // TrackBy Functions + // ============================================================================ + + /** TrackBy function for members */ + protected trackByMemberId(_index: number, member: RiskInsightsMember): string { + return member.memberId; + } + + /** TrackBy function for cipher items */ + protected trackByCipherId(_index: number, item: RiskInsightsItem): string { + return item.cipherId; + } +} 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 b9b543279b1..465ab21b178 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 @@ -112,13 +112,19 @@
- + @if (initialized()) { + + } - + @if (initialized()) { + + } - + @if (initialized()) { + + }
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 6f3b8e09032..9e96c155f76 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 @@ -23,6 +23,7 @@ import { RiskInsightsApplication, RiskInsightsItem, RiskInsightsItemStatus, + RiskInsightsMember, calculateRiskStatus, } from "./risk-insights-prototype.types"; @@ -55,6 +56,7 @@ export class RiskInsightsPrototypeOrchestrationService { private organizationId: OrganizationId | null = null; private currentUserId: UserId | null = null; private cipherIndexMap = new Map(); + private cipherItemMap = new Map(); private allCiphers: CipherView[] = []; private passwordUseMap: Map = new Map(); @@ -67,6 +69,18 @@ export class RiskInsightsPrototypeOrchestrationService { /** Maps cipher ID to the set of member IDs with access (for at-risk member tracking) */ private cipherToMemberIdsMap = new Map>(); + /** Maps member ID to cipher IDs they have access to */ + private memberToCiphersMap = new Map>(); + + /** Maps member ID to email */ + private memberEmailMap = new Map(); + + /** Stores processed cipher-member data for rebuilding aggregations */ + private processedCipherMemberData: CipherWithMemberAccess[] = []; + + /** Whether member aggregations have been built (for lazy loading) */ + private _memberAggregationsBuilt = false; + // ============================================================================ // Internal Signals (private, writable) // ============================================================================ @@ -89,6 +103,7 @@ export class RiskInsightsPrototypeOrchestrationService { // Results private readonly _items = signal([]); private readonly _applications = signal([]); + private readonly _members = signal([]); // Error state private readonly _error = signal(null); @@ -115,6 +130,7 @@ export class RiskInsightsPrototypeOrchestrationService { // Results readonly items = this._items.asReadonly(); readonly applications = this._applications.asReadonly(); + readonly members = this._members.asReadonly(); // Error state readonly error = this._error.asReadonly(); @@ -200,6 +216,7 @@ export class RiskInsightsPrototypeOrchestrationService { // Transform to items and display immediately const items = this.riskInsightsService.transformCiphersToItems(ciphers); this._items.set(items); + this.updateCipherItemMap(); // Build cipher index map for O(1) updates this.cipherIndexMap.clear(); @@ -235,8 +252,9 @@ export class RiskInsightsPrototypeOrchestrationService { this._processingPhase.set(ProcessingPhase.Complete); this._progressMessage.set(""); }, - error: (_err: unknown) => { - // HIBP check error - silently ignore for prototype + error: () => { + this._processingPhase.set(ProcessingPhase.Complete); + this._progressMessage.set(""); }, }); } else { @@ -262,6 +280,7 @@ export class RiskInsightsPrototypeOrchestrationService { resetState(): void { this._items.set([]); this._applications.set([]); + this._members.set([]); this._processingPhase.set(ProcessingPhase.Idle); this._progressMessage.set(""); this._cipherProgress.set({ current: 0, total: 0, percent: 0 }); @@ -270,11 +289,52 @@ export class RiskInsightsPrototypeOrchestrationService { this._hibpProgress.set({ current: 0, total: 0, percent: 0 }); this._error.set(null); this.cipherIndexMap.clear(); + this.cipherItemMap.clear(); this.allCiphers = []; this.passwordUseMap.clear(); this.cipherToApplicationsMap.clear(); this.applicationToCiphersMap.clear(); this.cipherToMemberIdsMap.clear(); + this.memberToCiphersMap.clear(); + this.memberEmailMap.clear(); + this.processedCipherMemberData = []; + this._memberAggregationsBuilt = false; + } + + /** + * Ensures member aggregations are built (lazy loading). + * Call this when the Members tab is first selected. + */ + ensureMemberAggregationsBuilt(): void { + if (this._memberAggregationsBuilt) { + return; + } + + if (this.processedCipherMemberData.length === 0) { + return; + } + + // Use setTimeout to yield to UI before potentially blocking work + setTimeout(() => { + this.buildMemberAggregations(this.processedCipherMemberData); + this._memberAggregationsBuilt = true; + }, 0); + } + + // ============================================================================ + // Private Methods - Cache Management + // ============================================================================ + + /** + * Updates the cached cipher item map for O(1) lookups. + * Must be called after every _items.set() to keep cache in sync. + */ + private updateCipherItemMap(): void { + const items = this._items(); + this.cipherItemMap.clear(); + for (const item of items) { + this.cipherItemMap.set(item.cipherId, item); + } } // ============================================================================ @@ -342,6 +402,7 @@ export class RiskInsightsPrototypeOrchestrationService { } this._items.set(currentItems); + this.updateCipherItemMap(); this._healthProgress.set({ current: processedCount, total: totalCiphers, @@ -405,8 +466,7 @@ export class RiskInsightsPrototypeOrchestrationService { }), last(), map((): void => undefined), - catchError((_err: unknown) => { - // Member access error - silently continue for prototype + catchError(() => { return of(undefined); }), ); @@ -438,6 +498,7 @@ export class RiskInsightsPrototypeOrchestrationService { if (hasChanges) { this._items.set(currentItems); + this.updateCipherItemMap(); } } @@ -516,6 +577,7 @@ export class RiskInsightsPrototypeOrchestrationService { if (hasChanges) { this._items.set(currentItems); + this.updateCipherItemMap(); // Update application at-risk counts after HIBP updates this.updateApplicationAtRiskCounts(); @@ -574,6 +636,7 @@ export class RiskInsightsPrototypeOrchestrationService { if (hasChanges) { this._items.set(currentItems); + this.updateCipherItemMap(); } } @@ -640,9 +703,10 @@ export class RiskInsightsPrototypeOrchestrationService { * @param processedCiphers - Ciphers with their member access data */ private updateApplicationsWithMemberCounts(processedCiphers: CipherWithMemberAccess[]): void { + // Store processed cipher-member data for member aggregation + this.processedCipherMemberData = processedCiphers; + 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>(); @@ -656,8 +720,8 @@ export class RiskInsightsPrototypeOrchestrationService { // 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); + // Check if this cipher is at-risk (use cached map for O(1) lookup) + const item = this.cipherItemMap.get(cipherId); const isAtRisk = item?.status === RiskInsightsItemStatus.AtRisk; for (const domain of domains) { @@ -702,8 +766,6 @@ export class RiskInsightsPrototypeOrchestrationService { * 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) => { @@ -711,7 +773,8 @@ export class RiskInsightsPrototypeOrchestrationService { const atRiskMemberIds = new Set(); for (const cipherId of app.cipherIds) { - const item = itemMap.get(cipherId); + // Use cached map for O(1) lookup + const item = this.cipherItemMap.get(cipherId); if (item?.status === RiskInsightsItemStatus.AtRisk) { atRiskCount++; @@ -733,5 +796,113 @@ export class RiskInsightsPrototypeOrchestrationService { }); this._applications.set(updatedApplications); + + // Update member at-risk counts + this.updateMemberAtRiskCounts(); + } + + // ============================================================================ + // Private Methods - Member Aggregation + // ============================================================================ + + /** + * Builds member aggregations from cipher-member data. + * Called after member access data is loaded to create the inverted view + * (member -> ciphers instead of cipher -> members). + * + * @param processedCiphers - Ciphers with their member access data + */ + private buildMemberAggregations(processedCiphers: CipherWithMemberAccess[]): void { + // Clear existing maps + this.memberToCiphersMap.clear(); + this.memberEmailMap.clear(); + + // Build inverted mapping: member -> ciphers + for (const processed of processedCiphers) { + const cipherId = processed.cipher.id; + + for (const member of processed.members) { + const memberId = member.userId; + + // Store email mapping + if (!this.memberEmailMap.has(memberId)) { + this.memberEmailMap.set(memberId, member.email ?? "(unknown)"); + } + + // Build member -> cipher mapping + if (!this.memberToCiphersMap.has(memberId)) { + this.memberToCiphersMap.set(memberId, new Set()); + } + this.memberToCiphersMap.get(memberId)!.add(cipherId); + } + } + + // Build member aggregations + const memberAggregations: RiskInsightsMember[] = []; + + for (const [memberId, cipherIds] of this.memberToCiphersMap) { + const cipherIdArray = Array.from(cipherIds); + + // Calculate at-risk ciphers for this member (use cached map for O(1) lookup) + const atRiskCipherIds: string[] = []; + for (const cipherId of cipherIdArray) { + const item = this.cipherItemMap.get(cipherId); + if (item?.status === RiskInsightsItemStatus.AtRisk) { + atRiskCipherIds.push(cipherId); + } + } + + memberAggregations.push({ + memberId, + email: this.memberEmailMap.get(memberId) ?? "(unknown)", + cipherCount: cipherIdArray.length, + atRiskCipherCount: atRiskCipherIds.length, + cipherIds: cipherIdArray, + atRiskCipherIds, + memberAccessPending: false, + }); + } + + // Sort by cipher count descending + memberAggregations.sort((a, b) => b.cipherCount - a.cipherCount); + + this._members.set(memberAggregations); + } + + /** + * Updates member at-risk counts based on current item statuses. + * Called after health checks complete or when item statuses change (e.g., HIBP results). + */ + private updateMemberAtRiskCounts(): void { + // Skip if member aggregations haven't been built yet (lazy loading) + if (!this._memberAggregationsBuilt) { + return; + } + + const currentMembers = this._members(); + + if (currentMembers.length === 0) { + return; + } + + const updatedMembers = currentMembers.map((member) => { + const atRiskCipherIds: string[] = []; + + for (const cipherId of member.cipherIds) { + // Use cached map for O(1) lookup + const item = this.cipherItemMap.get(cipherId); + if (item?.status === RiskInsightsItemStatus.AtRisk) { + atRiskCipherIds.push(cipherId); + } + } + + return { + ...member, + atRiskCipherCount: atRiskCipherIds.length, + atRiskCipherIds, + }; + }); + + this._members.set(updatedMembers); } } 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 25942e11ffc..454b60405c7 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 @@ -8,6 +8,7 @@ export { ProgressInfo, RiskInsightsItem, RiskInsightsApplication, + RiskInsightsMember, createRiskInsightsItem, calculateRiskStatus, } from "@bitwarden/common/dirt/reports/risk-insights/types"; 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 d6f26e883ca..bfdb4a6a676 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,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; -import { ProcessingPhase, ProgressInfo, RiskInsightsApplication, RiskInsightsItem } from "../types"; +import { + ProcessingPhase, + ProgressInfo, + RiskInsightsApplication, + RiskInsightsItem, + RiskInsightsMember, +} from "../types"; /** * Generic read-only signal interface for framework-agnostic abstraction. @@ -38,6 +44,7 @@ export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction { // Results (read-only signals) abstract readonly items: ReadonlySignal; abstract readonly applications: ReadonlySignal; + abstract readonly members: ReadonlySignal; // Error state (read-only signal) abstract readonly error: ReadonlySignal; @@ -59,4 +66,10 @@ export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction { // Actions abstract startProcessing(): void; abstract resetState(): void; + + /** + * Ensures member aggregations are built (lazy loading). + * Call this when the Members tab is first selected. + */ + abstract ensureMemberAggregationsBuilt(): void; } 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 03edaa0f589..171d353641f 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 @@ -56,6 +56,26 @@ export interface RiskInsightsApplication { cipherIds: string[]; } +/** + * Represents a member aggregation in the Members tab + */ +export interface RiskInsightsMember { + /** Organization user ID */ + memberId: string; + /** Member email address */ + email: string; + /** Total number of ciphers this member has access to */ + cipherCount: number; + /** Number of at-risk ciphers this member has access to */ + atRiskCipherCount: number; + /** IDs of all ciphers the member can access (for drill-down) */ + cipherIds: string[]; + /** IDs of at-risk ciphers the member can access (for drill-down) */ + atRiskCipherIds: string[]; + /** Whether member data is still being loaded */ + memberAccessPending: boolean; +} + /** * Represents a single item in the risk insights report */