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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user