mirror of
https://github.com/bitwarden/browser
synced 2026-01-27 14:53:44 +00:00
members tab and refactoring
This commit is contained in:
@@ -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<string>());
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,181 @@
|
||||
<div class="tw-p-4">
|
||||
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "members" | i18n }}</h2>
|
||||
<p class="tw-text-muted">
|
||||
{{ "riskInsightsPrototypeMembersPlaceholder" | i18n }}
|
||||
</p>
|
||||
<div class="tw-flex tw-flex-col tw-gap-4">
|
||||
<!-- Loading State -->
|
||||
@if (isMemberDataLoading() && members().length === 0) {
|
||||
<div class="tw-text-center tw-py-8">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted" aria-hidden="true"></i>
|
||||
<p class="tw-mt-4 tw-text-muted">
|
||||
{{ "loading" | i18n }}
|
||||
@if (memberProgress().total > 0) {
|
||||
({{ memberProgress().current | number }}/{{ memberProgress().total | number }})
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results Table -->
|
||||
@if (members().length > 0) {
|
||||
<div class="tw-text-sm tw-text-muted tw-mb-2">
|
||||
{{ "totalMembers" | i18n }}: {{ members().length | number }}
|
||||
</div>
|
||||
|
||||
<table bitTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-8"></th>
|
||||
<th bitCell class="tw-min-w-[200px]">{{ "email" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-32">{{ "passwords" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-32">{{ "atRiskPasswords" | i18n }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of members(); track member.memberId) {
|
||||
<!-- Member Row (clickable) -->
|
||||
<tr
|
||||
bitRow
|
||||
class="tw-cursor-pointer hover:tw-bg-background-alt"
|
||||
(click)="toggleExpanded(member.memberId)"
|
||||
>
|
||||
<!-- Expand/Collapse Icon -->
|
||||
<td bitCell class="tw-text-center">
|
||||
<i
|
||||
class="bwi tw-text-muted"
|
||||
[class.bwi-angle-right]="!isExpanded(member.memberId)"
|
||||
[class.bwi-angle-down]="isExpanded(member.memberId)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</td>
|
||||
|
||||
<!-- Member Email -->
|
||||
<td bitCell>
|
||||
<div class="tw-font-medium">{{ member.email }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Cipher Count -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (member.memberAccessPending) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else {
|
||||
<span>{{ member.cipherCount | number }}</span>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- At-Risk Cipher Count -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (member.memberAccessPending) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else if (member.atRiskCipherCount > 0) {
|
||||
<span bitBadge variant="danger">{{ member.atRiskCipherCount | number }}</span>
|
||||
} @else {
|
||||
<span class="tw-text-muted">0</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded Cipher Rows -->
|
||||
@if (isExpanded(member.memberId)) {
|
||||
@for (cipher of getCiphersForMember(member.cipherIds); track cipher.cipherId) {
|
||||
<tr bitRow class="tw-bg-background-alt">
|
||||
<!-- Empty cell for alignment -->
|
||||
<td bitCell></td>
|
||||
|
||||
<!-- Cipher Name (indented) -->
|
||||
<td bitCell>
|
||||
<div class="tw-pl-4">
|
||||
<div
|
||||
class="tw-font-medium tw-truncate tw-max-w-[180px]"
|
||||
[title]="cipher.cipherName"
|
||||
>
|
||||
{{ cipher.cipherName }}
|
||||
</div>
|
||||
@if (cipher.cipherSubtitle) {
|
||||
<div class="tw-text-xs tw-text-muted tw-truncate tw-max-w-[180px]">
|
||||
{{ cipher.cipherSubtitle }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Health Status (combined cell) -->
|
||||
<td bitCell class="tw-text-center">
|
||||
<div class="tw-flex tw-justify-center tw-gap-2">
|
||||
<!-- Weak -->
|
||||
@if (cipher.weakPassword === true) {
|
||||
<span bitBadge variant="warning" title="{{ 'weak' | i18n }}">W</span>
|
||||
}
|
||||
<!-- Reused -->
|
||||
@if (cipher.reusedPassword === true) {
|
||||
<span bitBadge variant="warning" title="{{ 'reused' | i18n }}">R</span>
|
||||
}
|
||||
<!-- Exposed -->
|
||||
@if (cipher.exposedPassword === true) {
|
||||
<span
|
||||
bitBadge
|
||||
variant="danger"
|
||||
[title]="cipher.exposedCount + ' ' + ('timesExposed' | i18n)"
|
||||
>E</span
|
||||
>
|
||||
}
|
||||
<!-- Healthy -->
|
||||
@if (
|
||||
cipher.status === RiskInsightsItemStatus.Healthy &&
|
||||
!cipher.weakPassword &&
|
||||
!cipher.reusedPassword &&
|
||||
!cipher.exposedPassword
|
||||
) {
|
||||
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
|
||||
}
|
||||
<!-- Loading -->
|
||||
@if (cipher.status === null) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (cipher.status === null) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else if (cipher.status === RiskInsightsItemStatus.AtRisk) {
|
||||
<span bitBadge variant="danger">{{ "atRisk" | i18n }}</span>
|
||||
} @else {
|
||||
<span bitBadge variant="success">{{ "healthy" | i18n }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else if (processingPhase() === ProcessingPhase.Idle) {
|
||||
<!-- Initial State -->
|
||||
<div class="tw-text-center tw-py-8 tw-text-muted">
|
||||
<i class="bwi bwi-family tw-text-4xl tw-mb-4" aria-hidden="true"></i>
|
||||
<p>{{ "riskInsightsPrototypeMembersPlaceholder" | i18n }}</p>
|
||||
</div>
|
||||
} @else if (
|
||||
!isMemberDataLoading() && members().length === 0 && processingPhase() !== ProcessingPhase.Idle
|
||||
) {
|
||||
<!-- No Members Found -->
|
||||
<div class="tw-text-center tw-py-8 tw-text-muted">
|
||||
<i class="bwi bwi-family tw-text-4xl tw-mb-4" aria-hidden="true"></i>
|
||||
<p>{{ "noMembersInList" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<RiskInsightsMember>();
|
||||
|
||||
/** Row size for virtual scrolling (in pixels) */
|
||||
protected readonly ROW_SIZE = 52;
|
||||
|
||||
/** Set of expanded member IDs */
|
||||
protected readonly expandedMembers = signal(new Set<string>());
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +112,19 @@
|
||||
<div class="tw-flex-1 tw-flex tw-flex-col">
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
||||
<bit-tab [label]="'items' | i18n">
|
||||
<app-risk-insights-prototype-items></app-risk-insights-prototype-items>
|
||||
@if (initialized()) {
|
||||
<app-risk-insights-prototype-items></app-risk-insights-prototype-items>
|
||||
}
|
||||
</bit-tab>
|
||||
<bit-tab [label]="'applications' | i18n">
|
||||
<app-risk-insights-prototype-applications></app-risk-insights-prototype-applications>
|
||||
@if (initialized()) {
|
||||
<app-risk-insights-prototype-applications></app-risk-insights-prototype-applications>
|
||||
}
|
||||
</bit-tab>
|
||||
<bit-tab [label]="'members' | i18n">
|
||||
<app-risk-insights-prototype-members></app-risk-insights-prototype-members>
|
||||
@if (initialized()) {
|
||||
<app-risk-insights-prototype-members></app-risk-insights-prototype-members>
|
||||
}
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
</div>
|
||||
|
||||
@@ -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<string, number>();
|
||||
private cipherItemMap = new Map<string, RiskInsightsItem>();
|
||||
private allCiphers: CipherView[] = [];
|
||||
private passwordUseMap: Map<string, string[]> = 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<string, Set<string>>();
|
||||
|
||||
/** Maps member ID to cipher IDs they have access to */
|
||||
private memberToCiphersMap = new Map<string, Set<string>>();
|
||||
|
||||
/** Maps member ID to email */
|
||||
private memberEmailMap = new Map<string, string>();
|
||||
|
||||
/** 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<RiskInsightsItem[]>([]);
|
||||
private readonly _applications = signal<RiskInsightsApplication[]>([]);
|
||||
private readonly _members = signal<RiskInsightsMember[]>([]);
|
||||
|
||||
// Error state
|
||||
private readonly _error = signal<string | null>(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<memberIds> map
|
||||
const domainMemberMap = new Map<string, Set<string>>();
|
||||
@@ -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<string>();
|
||||
|
||||
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<string>());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
ProgressInfo,
|
||||
RiskInsightsItem,
|
||||
RiskInsightsApplication,
|
||||
RiskInsightsMember,
|
||||
createRiskInsightsItem,
|
||||
calculateRiskStatus,
|
||||
} from "@bitwarden/common/dirt/reports/risk-insights/types";
|
||||
|
||||
@@ -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<RiskInsightsItem[]>;
|
||||
abstract readonly applications: ReadonlySignal<RiskInsightsApplication[]>;
|
||||
abstract readonly members: ReadonlySignal<RiskInsightsMember[]>;
|
||||
|
||||
// Error state (read-only signal)
|
||||
abstract readonly error: ReadonlySignal<string | null>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user