1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 11:54:02 +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>