1
0
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:
Tom
2026-01-20 15:16:28 -05:00
parent c45f38a1f3
commit 03fdae924d
8 changed files with 571 additions and 28 deletions

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -8,6 +8,7 @@ export {
ProgressInfo,
RiskInsightsItem,
RiskInsightsApplication,
RiskInsightsMember,
createRiskInsightsItem,
calculateRiskStatus,
} from "@bitwarden/common/dirt/reports/risk-insights/types";

View File

@@ -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;
}

View File

@@ -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
*/