1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 17:13:24 +00:00

applications in prototype

This commit is contained in:
Tom
2026-01-16 15:32:46 -05:00
parent cb5f2f0a73
commit 656457204c
15 changed files with 996 additions and 265 deletions

View File

@@ -0,0 +1,77 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h2">Bulk Collection Seeder</h2>
<p bitTypography="body1" class="tw-text-muted">
Create multiple collections at once for testing purposes. Enter one collection name per line.
</p>
</bit-section-header>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-form-field>
<bit-label>Collection Names (one per line)</bit-label>
<textarea
bitInput
[(ngModel)]="collectionNames"
rows="10"
placeholder="Collection 1&#10;Collection 2&#10;Parent/Child Collection&#10;..."
[disabled]="isProcessing()"
></textarea>
<bit-hint
>Enter collection names, one per line. Use "/" for nested collections (e.g.,
"Parent/Child").</bit-hint
>
</bit-form-field>
<div class="tw-flex tw-gap-2">
<button
bitButton
buttonType="primary"
[disabled]="isProcessing() || !collectionNames.trim()"
(click)="createCollections()"
>
<span *ngIf="!isProcessing()">Create Collections</span>
<span *ngIf="isProcessing()">Creating...</span>
</button>
<button
bitButton
buttonType="secondary"
[disabled]="isProcessing() || !hasRun()"
(click)="clearResults()"
>
Clear Results
</button>
</div>
<div *ngIf="isProcessing() || hasRun()" class="tw-mt-4">
<bit-progress [barWidth]="progress()"></bit-progress>
<p bitTypography="body2" class="tw-mt-2 tw-text-muted">{{ progressMessage() }}</p>
</div>
<div *ngIf="hasRun() && !isProcessing()" class="tw-mt-4">
<h3 bitTypography="h3">Results</h3>
<p bitTypography="body1">
<span class="tw-text-success">{{ successCount }} succeeded</span>
<span *ngIf="failureCount > 0"
>, <span class="tw-text-danger">{{ failureCount }} failed</span></span
>
</p>
<div
*ngIf="results().length > 0"
class="tw-mt-2 tw-max-h-64 tw-overflow-y-auto tw-border tw-border-secondary-300 tw-rounded tw-p-2"
>
<div
*ngFor="let result of results()"
class="tw-flex tw-items-center tw-gap-2 tw-py-1 tw-border-b tw-border-secondary-100 last:tw-border-b-0"
>
<span *ngIf="result.success" class="tw-text-success">&#10003;</span>
<span *ngIf="!result.success" class="tw-text-danger">&#10007;</span>
<span>{{ result.name }}</span>
<span *ngIf="!result.success" class="tw-text-danger tw-text-sm"
>- {{ result.error }}</span
>
</div>
</div>
</div>
</div>
</bit-section>

View File

@@ -0,0 +1,149 @@
import { CommonModule } from "@angular/common";
import {
Component,
ChangeDetectionStrategy,
DestroyRef,
inject,
OnInit,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import {
ButtonModule,
FormFieldModule,
ProgressModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
interface CollectionCreationResult {
name: string;
success: boolean;
error?: string;
id?: CollectionId;
}
@Component({
selector: "app-bulk-collection-seeder",
templateUrl: "./bulk-collection-seeder.component.html",
standalone: true,
imports: [
CommonModule,
FormsModule,
JslibModule,
HeaderModule,
ButtonModule,
FormFieldModule,
ProgressModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BulkCollectionSeederComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private route = inject(ActivatedRoute);
private collectionAdminService = inject(CollectionAdminService);
private accountService = inject(AccountService);
protected organizationId: OrganizationId | null = null;
protected collectionNames = "";
protected readonly isProcessing = signal(false);
protected readonly progress = signal(0);
protected readonly progressMessage = signal("");
protected readonly results = signal<CollectionCreationResult[]>([]);
protected readonly hasRun = signal(false);
ngOnInit(): void {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
this.organizationId = params["organizationId"] as OrganizationId;
});
}
protected async createCollections(): Promise<void> {
if (!this.organizationId || !this.collectionNames.trim()) {
return;
}
const names = this.collectionNames
.split("\n")
.map((name) => name.trim())
.filter((name) => name.length > 0);
if (names.length === 0) {
return;
}
this.isProcessing.set(true);
this.progress.set(0);
this.results.set([]);
this.hasRun.set(true);
const results: CollectionCreationResult[] = [];
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
for (let i = 0; i < names.length; i++) {
const name = names[i];
this.progressMessage.set(`Creating collection ${i + 1} of ${names.length}: ${name}`);
this.progress.set(Math.round(((i + 1) / names.length) * 100));
try {
const collectionView = new CollectionAdminView({
id: null as unknown as CollectionId,
organizationId: this.organizationId,
name: name,
});
collectionView.groups = [];
collectionView.users = [];
const response = await this.collectionAdminService.create(collectionView, userId);
results.push({
name,
success: true,
id: response.id,
});
} catch (error) {
results.push({
name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
this.results.set([...results]);
}
this.progressMessage.set(
`Completed: ${results.filter((r) => r.success).length} of ${names.length} collections created`,
);
this.isProcessing.set(false);
}
protected get successCount(): number {
return this.results().filter((r) => r.success).length;
}
protected get failureCount(): number {
return this.results().filter((r) => !r.success).length;
}
protected clearResults(): void {
this.results.set([]);
this.hasRun.set(false);
this.progress.set(0);
this.progressMessage.set("");
}
}

View File

@@ -21,7 +21,7 @@ import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { organizationRedirectGuard } from "../guards/org-redirect.guard";
import { EventsComponent } from "../manage/events.component";
import { CipherHealthTestComponent } from "./cipher-health-test.component";
import { BulkCollectionSeederComponent } from "./bulk-collection-seeder/bulk-collection-seeder.component";
import { ReportsHomeComponent } from "./reports-home.component";
import { RiskInsightsPrototypeComponent } from "./risk-insights-prototype/risk-insights-prototype.component";
@@ -84,14 +84,6 @@ const routes: Routes = [
},
canActivate: [isPaidOrgGuard()],
},
{
path: "cipher-health-test",
component: CipherHealthTestComponent,
data: {
titleId: "cipherHealthTest",
},
canActivate: [isPaidOrgGuard()],
},
{
path: "risk-insights-prototype",
component: RiskInsightsPrototypeComponent,
@@ -100,6 +92,14 @@ const routes: Routes = [
},
canActivate: [isPaidOrgGuard()],
},
{
path: "bulk-collection-seeder",
component: BulkCollectionSeederComponent,
data: {
titleId: "bulkCollectionSeeder",
},
canActivate: [isPaidOrgGuard()],
},
],
},
{

View File

@@ -4,7 +4,7 @@ import { ReportsSharedModule } from "../../../dirt/reports";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { CipherHealthTestComponent } from "./cipher-health-test.component";
import { BulkCollectionSeederComponent } from "./bulk-collection-seeder/bulk-collection-seeder.component";
import { OrganizationReportingRoutingModule } from "./organization-reporting-routing.module";
import { ReportsHomeComponent } from "./reports-home.component";
import { RiskInsightsPrototypeComponent } from "./risk-insights-prototype/risk-insights-prototype.component";
@@ -15,8 +15,8 @@ import { RiskInsightsPrototypeComponent } from "./risk-insights-prototype/risk-i
ReportsSharedModule,
OrganizationReportingRoutingModule,
HeaderModule,
CipherHealthTestComponent,
RiskInsightsPrototypeComponent,
BulkCollectionSeederComponent,
],
declarations: [ReportsHomeComponent],
})

View File

@@ -1,6 +1,189 @@
<div class="tw-p-4">
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "applications" | i18n }}</h2>
<p class="tw-text-muted">
{{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }}
</p>
<div class="tw-flex tw-flex-col tw-gap-4">
<!-- Results Table -->
@if (applications().length > 0) {
<div class="tw-text-sm tw-text-muted tw-mb-2">
{{ "totalApplications" | i18n }}: {{ applications().length | number }}
</div>
<table bitTable>
<thead>
<tr>
<th bitCell class="tw-w-8"></th>
<th bitCell class="tw-min-w-[200px]">{{ "application" | 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>
<th bitCell class="tw-text-center tw-w-32">{{ "members" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-32">{{ "atRiskMembers" | i18n }}</th>
</tr>
</thead>
<tbody>
@for (app of applications(); track app.domain) {
<!-- Application Row (clickable) -->
<tr
bitRow
class="tw-cursor-pointer hover:tw-bg-background-alt"
(click)="toggleExpanded(app.domain)"
>
<!-- Expand/Collapse Icon -->
<td bitCell class="tw-text-center">
<i
class="bwi tw-text-muted"
[class.bwi-angle-right]="!isExpanded(app.domain)"
[class.bwi-angle-down]="isExpanded(app.domain)"
aria-hidden="true"
></i>
</td>
<!-- Application Domain -->
<td bitCell>
<div class="tw-font-medium">{{ app.domain }}</div>
</td>
<!-- Password Count -->
<td bitCell class="tw-text-center">
<span>{{ app.passwordCount | number }}</span>
</td>
<!-- At-Risk Password Count -->
<td bitCell class="tw-text-center">
@if (app.atRiskPasswordCount > 0) {
<span bitBadge variant="danger">{{ app.atRiskPasswordCount | number }}</span>
} @else {
<span class="tw-text-muted">0</span>
}
</td>
<!-- Member Count -->
<td bitCell class="tw-text-center">
@if (app.memberAccessPending) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span>{{ app.memberIds.size | number }}</span>
}
</td>
<!-- At-Risk Member Count -->
<td bitCell class="tw-text-center">
@if (app.memberAccessPending) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else if (app.atRiskMemberIds.size > 0) {
<span bitBadge variant="danger">{{ app.atRiskMemberIds.size | number }}</span>
} @else {
<span class="tw-text-muted">0</span>
}
</td>
</tr>
<!-- Expanded Cipher Rows -->
@if (isExpanded(app.domain)) {
@for (cipher of getCiphersForApplication(app.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 colspan="2" 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>
<!-- Member Count -->
<td bitCell class="tw-text-center">
@if (cipher.memberAccessPending) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span>{{ cipher.memberCount | number }}</span>
}
</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-collection tw-text-4xl tw-mb-4" aria-hidden="true"></i>
<p>{{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }}</p>
</div>
}
</div>

View File

@@ -1,13 +1,128 @@
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { ChangeDetectionStrategy, Component, 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,
RiskInsightsApplication,
RiskInsightsItem,
RiskInsightsItemStatus,
} from "@bitwarden/common/dirt/reports/risk-insights";
import { BadgeModule, TableDataSource, TableModule } from "@bitwarden/components";
/* eslint-enable no-restricted-imports */
/**
* Applications tab component for the Risk Insights Prototype.
*
* Displays a table of applications (domains) with aggregated cipher data.
* Features:
* - Expandable rows to show ciphers within each application
* - Virtual scrolling table for large datasets
* - Distinct member counts per application
*/
@Component({
selector: "app-risk-insights-prototype-applications",
templateUrl: "./risk-insights-prototype-applications.component.html",
standalone: true,
imports: [CommonModule, JslibModule],
imports: [CommonModule, JslibModule, TableModule, BadgeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeApplicationsComponent {}
export class RiskInsightsPrototypeApplicationsComponent {
// ============================================================================
// Injected Dependencies
// ============================================================================
private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
// ============================================================================
// Expose Orchestrator Signals to Template
// ============================================================================
// Configuration flags (for conditional rendering in expanded rows)
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
readonly enableHibp = this.orchestrator.enableHibp;
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
// Processing state
readonly processingPhase = this.orchestrator.processingPhase;
// Results
readonly applications = this.orchestrator.applications;
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<RiskInsightsApplication>();
/** Row size for virtual scrolling (in pixels) */
protected readonly ROW_SIZE = 52;
/** Set of expanded application domains */
protected readonly expandedApplications = signal(new Set<string>());
// ============================================================================
// Lifecycle
// ============================================================================
constructor() {
// Effect to sync applications signal to table data source
effect(() => {
const applications = this.applications();
this.dataSource.data = applications;
});
}
// ============================================================================
// Expansion Methods
// ============================================================================
/** Toggle expansion state for an application */
protected toggleExpanded(domain: string): void {
this.expandedApplications.update((current) => {
const newSet = new Set(current);
if (newSet.has(domain)) {
newSet.delete(domain);
} else {
newSet.add(domain);
}
return newSet;
});
}
/** Check if an application is expanded */
protected isExpanded(domain: string): boolean {
return this.expandedApplications().has(domain);
}
/** 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]));
return cipherIds
.map((id) => itemMap.get(id))
.filter((item): item is RiskInsightsItem => item !== undefined);
}
// ============================================================================
// TrackBy Functions
// ============================================================================
/** TrackBy function for applications */
protected trackByDomain(_index: number, app: RiskInsightsApplication): string {
return app.domain;
}
/** TrackBy function for cipher items */
protected trackByCipherId(_index: number, item: RiskInsightsItem): string {
return item.cipherId;
}
}

View File

@@ -1,100 +1,4 @@
<div class="tw-flex tw-flex-col tw-gap-4">
<!-- Controls Section -->
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-6 tw-mb-4">
<!-- Weak Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableWeakPassword()"
(change)="toggleWeakPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableWeakPasswordCheck" | i18n }}</span>
</label>
<!-- HIBP Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableHibp()"
(change)="toggleHibp()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableHibpCheck" | i18n }}</span>
</label>
<!-- Reused Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableReusedPassword()"
(change)="toggleReusedPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableReusedPasswordCheck" | i18n }}</span>
</label>
</div>
<!-- Run Report Button -->
<button bitButton buttonType="primary" (click)="runReport()" [disabled]="isProcessing()">
@if (isProcessing()) {
<i class="bwi bwi-spinner bwi-spin tw-mr-2" aria-hidden="true"></i>
{{ "processing" | i18n }}
} @else {
<i class="bwi bwi-play tw-mr-2" aria-hidden="true"></i>
{{ "runReport" | i18n }}
}
</button>
</div>
<!-- Progress Section -->
@if (showProgress()) {
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
<!-- Main Progress -->
<div class="tw-mb-4">
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">{{ progressMessage() || "Processing..." }}</span>
<span class="tw-text-muted">{{ getOverallProgress() | number: "1.0-0" }}%</span>
</div>
<bit-progress [barWidth]="getOverallProgress()" bgColor="primary"></bit-progress>
</div>
<!-- HIBP Progress (only when HIBP is enabled and running) -->
@if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
<div>
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">
{{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
{{ hibpProgress().total }}
</span>
<span class="tw-text-muted">{{ hibpProgress().percent }}%</span>
</div>
<bit-progress [barWidth]="hibpProgress().percent" bgColor="warning"></bit-progress>
</div>
}
<!-- Completion Status -->
@if (processingPhase() === ProcessingPhase.Complete) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-success-600">
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
<span>{{ "reportComplete" | i18n }}</span>
</div>
}
<!-- Error Status -->
@if (processingPhase() === ProcessingPhase.Error && error()) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-danger-600">
<i class="bwi bwi-error" aria-hidden="true"></i>
<span>{{ error() }}</span>
</div>
}
</div>
}
<!-- Results Table -->
@if (items().length > 0) {
<div class="tw-text-sm tw-text-muted tw-mb-2">

View File

@@ -1,104 +1,51 @@
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
OnInit,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { ChangeDetectionStrategy, Component, effect, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
CipherAccessMappingService,
PasswordHealthService,
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import { RiskInsightsPrototypeOrchestrationService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import {
ProcessingPhase,
RiskInsightsItem,
RiskInsightsItemStatus,
} from "@bitwarden/common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
BadgeModule,
ButtonModule,
CheckboxModule,
ProgressModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { BadgeModule, TableDataSource, TableModule } from "@bitwarden/components";
/* eslint-enable no-restricted-imports */
/**
* Items tab component for the Risk Insights Prototype.
*
* Displays a table of cipher items with health status and member counts.
* Features:
* - Progressive loading with status indicators
* - Virtual scrolling table for large datasets
* - Configurable health checks (weak, reused, exposed passwords)
* The orchestrator is provided by the parent component and shared across tabs.
*/
@Component({
selector: "app-risk-insights-prototype-items",
templateUrl: "./risk-insights-prototype-items.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
TableModule,
ProgressModule,
CheckboxModule,
ButtonModule,
BadgeModule,
],
providers: [
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
CipherAccessMappingService,
PasswordHealthService,
],
imports: [CommonModule, JslibModule, TableModule, BadgeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeItemsComponent implements OnInit {
export class RiskInsightsPrototypeItemsComponent {
// ============================================================================
// Injected Dependencies
// ============================================================================
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
// ============================================================================
// Expose Orchestrator Signals to Template
// ============================================================================
// Configuration flags
// Configuration flags (for conditional rendering in template)
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
readonly enableHibp = this.orchestrator.enableHibp;
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
// Processing state
readonly processingPhase = this.orchestrator.processingPhase;
readonly progressMessage = this.orchestrator.progressMessage;
// Progress tracking
readonly cipherProgress = this.orchestrator.cipherProgress;
readonly healthProgress = this.orchestrator.healthProgress;
readonly memberProgress = this.orchestrator.memberProgress;
readonly hibpProgress = this.orchestrator.hibpProgress;
// Results
readonly items = this.orchestrator.items;
// Error state
readonly error = this.orchestrator.error;
// Expose constants for template access
readonly ProcessingPhase = ProcessingPhase;
readonly RiskInsightsItemStatus = RiskInsightsItemStatus;
@@ -113,9 +60,6 @@ export class RiskInsightsPrototypeItemsComponent implements OnInit {
/** Row size for virtual scrolling (in pixels) */
protected readonly ROW_SIZE = 52;
/** Whether the component has been initialized */
protected readonly initialized = signal(false);
// ============================================================================
// Lifecycle
// ============================================================================
@@ -128,82 +72,6 @@ export class RiskInsightsPrototypeItemsComponent implements OnInit {
});
}
ngOnInit(): void {
// Get organization ID from route and initialize orchestrator
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const organizationId = params["organizationId"] as OrganizationId;
if (organizationId) {
this.orchestrator.initializeForOrganization(organizationId);
this.initialized.set(true);
}
});
}
// ============================================================================
// UI Actions
// ============================================================================
/** Start processing - run the report */
protected runReport(): void {
this.orchestrator.startProcessing();
}
/** Toggle weak password check */
protected toggleWeakPassword(): void {
this.orchestrator.toggleEnableWeakPassword();
}
/** Toggle HIBP check */
protected toggleHibp(): void {
this.orchestrator.toggleEnableHibp();
}
/** Toggle reused password check */
protected toggleReusedPassword(): void {
this.orchestrator.toggleEnableReusedPassword();
}
// ============================================================================
// Computed Properties
// ============================================================================
/** Check if processing is currently running */
protected isProcessing(): boolean {
const phase = this.processingPhase();
return (
phase !== ProcessingPhase.Idle &&
phase !== ProcessingPhase.Complete &&
phase !== ProcessingPhase.Error
);
}
/** Check if progress section should be shown */
protected showProgress(): boolean {
return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
}
/** Calculate overall progress percentage */
protected getOverallProgress(): number {
const phase = this.processingPhase();
switch (phase) {
case ProcessingPhase.Idle:
return 0;
case ProcessingPhase.LoadingCiphers:
return this.cipherProgress().percent * 0.2; // 0-20%
case ProcessingPhase.RunningHealthChecks:
return 20 + this.healthProgress().percent * 0.2; // 20-40%
case ProcessingPhase.LoadingMembers:
return 40 + this.memberProgress().percent * 0.4; // 40-80%
case ProcessingPhase.RunningHibp:
return 80 + this.hibpProgress().percent * 0.2; // 80-100%
case ProcessingPhase.Complete:
return 100;
default:
return 0;
}
}
// ============================================================================
// TrackBy Functions
// ============================================================================

View File

@@ -7,6 +7,108 @@
</p>
</div>
<!-- Controls Section -->
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg tw-mb-4">
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-6 tw-mb-4">
<!-- Weak Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableWeakPassword()"
(change)="toggleWeakPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableWeakPasswordCheck" | i18n }}</span>
</label>
<!-- HIBP Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableHibp()"
(change)="toggleHibp()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableHibpCheck" | i18n }}</span>
</label>
<!-- Reused Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableReusedPassword()"
(change)="toggleReusedPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableReusedPasswordCheck" | i18n }}</span>
</label>
</div>
<!-- Run Report Button -->
<button
type="button"
bitButton
buttonType="primary"
(click)="runReport()"
[disabled]="isProcessing()"
>
@if (isProcessing()) {
<i class="bwi bwi-spinner bwi-spin tw-mr-2" aria-hidden="true"></i>
{{ "processing" | i18n }}
} @else {
<i class="bwi bwi-play tw-mr-2" aria-hidden="true"></i>
{{ "runReport" | i18n }}
}
</button>
</div>
<!-- Progress Section -->
@if (showProgress()) {
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg tw-mb-4">
<!-- Main Progress -->
<div class="tw-mb-4">
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">{{ progressMessage() || "Processing..." }}</span>
<span class="tw-text-muted">{{ getOverallProgress() | number: "1.0-0" }}%</span>
</div>
<bit-progress [barWidth]="getOverallProgress()" bgColor="primary"></bit-progress>
</div>
<!-- HIBP Progress (only when HIBP is enabled and running) -->
@if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
<div>
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">
{{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
{{ hibpProgress().total }}
</span>
<span class="tw-text-muted">{{ hibpProgress().percent }}%</span>
</div>
<bit-progress [barWidth]="hibpProgress().percent" bgColor="warning"></bit-progress>
</div>
}
<!-- Completion Status -->
@if (processingPhase() === ProcessingPhase.Complete) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-success-600">
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
<span>{{ "reportComplete" | i18n }}</span>
</div>
}
<!-- Error Status -->
@if (processingPhase() === ProcessingPhase.Error && error()) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-danger-600">
<i class="bwi bwi-error" aria-hidden="true"></i>
<span>{{ error() }}</span>
</div>
}
</div>
}
<div class="tw-flex-1 tw-flex tw-flex-col">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
<bit-tab [label]="'items' | i18n">

View File

@@ -1,15 +1,32 @@
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
import { Component, ChangeDetectionStrategy, DestroyRef, inject } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
DestroyRef,
inject,
OnInit,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { TabsModule } from "@bitwarden/components";
import {
CipherAccessMappingService,
PasswordHealthService,
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import { ProcessingPhase } from "@bitwarden/common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, CheckboxModule, ProgressModule, TabsModule } from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { RiskInsightsPrototypeApplicationsComponent } from "./applications/risk-insights-prototype-applications.component";
import { RiskInsightsPrototypeItemsComponent } from "./items/risk-insights-prototype-items.component";
import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-prototype-members.component";
/* eslint-enable no-restricted-imports */
@Component({
selector: "app-risk-insights-prototype",
@@ -20,26 +37,64 @@ import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-p
JslibModule,
TabsModule,
HeaderModule,
ButtonModule,
CheckboxModule,
ProgressModule,
RiskInsightsPrototypeItemsComponent,
RiskInsightsPrototypeApplicationsComponent,
RiskInsightsPrototypeMembersComponent,
],
providers: [
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
CipherAccessMappingService,
PasswordHealthService,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeComponent {
export class RiskInsightsPrototypeComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private route = inject(ActivatedRoute);
private router = inject(Router);
protected readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
tabIndex = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
) {
// Expose orchestrator signals to template
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
readonly enableHibp = this.orchestrator.enableHibp;
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
readonly processingPhase = this.orchestrator.processingPhase;
readonly progressMessage = this.orchestrator.progressMessage;
readonly cipherProgress = this.orchestrator.cipherProgress;
readonly healthProgress = this.orchestrator.healthProgress;
readonly memberProgress = this.orchestrator.memberProgress;
readonly hibpProgress = this.orchestrator.hibpProgress;
readonly error = this.orchestrator.error;
// Expose constants for template access
readonly ProcessingPhase = ProcessingPhase;
// Component initialization state
protected readonly initialized = signal(false);
constructor() {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : 0;
});
}
ngOnInit(): void {
// Get organization ID from route and initialize orchestrator
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const organizationId = params["organizationId"] as OrganizationId;
if (organizationId) {
this.orchestrator.initializeForOrganization(organizationId);
this.initialized.set(true);
}
});
}
async onTabChange(newIndex: number): Promise<void> {
await this.router.navigate([], {
relativeTo: this.route,
@@ -47,4 +102,69 @@ export class RiskInsightsPrototypeComponent {
queryParamsHandling: "merge",
});
}
// ============================================================================
// UI Actions
// ============================================================================
/** Start processing - run the report */
protected runReport(): void {
this.orchestrator.startProcessing();
}
/** Toggle weak password check */
protected toggleWeakPassword(): void {
this.orchestrator.toggleEnableWeakPassword();
}
/** Toggle HIBP check */
protected toggleHibp(): void {
this.orchestrator.toggleEnableHibp();
}
/** Toggle reused password check */
protected toggleReusedPassword(): void {
this.orchestrator.toggleEnableReusedPassword();
}
// ============================================================================
// Computed Properties
// ============================================================================
/** Check if processing is currently running */
protected isProcessing(): boolean {
const phase = this.processingPhase();
return (
phase !== ProcessingPhase.Idle &&
phase !== ProcessingPhase.Complete &&
phase !== ProcessingPhase.Error
);
}
/** Check if progress section should be shown */
protected showProgress(): boolean {
return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
}
/** Calculate overall progress percentage */
protected getOverallProgress(): number {
const phase = this.processingPhase();
switch (phase) {
case ProcessingPhase.Idle:
return 0;
case ProcessingPhase.LoadingCiphers:
return this.cipherProgress().percent * 0.2; // 0-20%
case ProcessingPhase.RunningHealthChecks:
return 20 + this.healthProgress().percent * 0.2; // 20-40%
case ProcessingPhase.LoadingMembers:
return 40 + this.memberProgress().percent * 0.4; // 40-80%
case ProcessingPhase.RunningHibp:
return 80 + this.hibpProgress().percent * 0.2; // 80-100%
case ProcessingPhase.Complete:
return 100;
default:
return 0;
}
}
}

View File

@@ -8,12 +8,19 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAccessMappingService, MemberAccessLoadState } from "./cipher-access-mapping.service";
import { getTrimmedCipherUris } from "../../helpers/risk-insights-data-mappers";
import {
CipherAccessMappingService,
CipherWithMemberAccess,
MemberAccessLoadState,
} from "./cipher-access-mapping.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsPrototypeService } from "./risk-insights-prototype.service";
import {
ProcessingPhase,
ProgressInfo,
RiskInsightsApplication,
RiskInsightsItem,
RiskInsightsItemStatus,
calculateRiskStatus,
@@ -51,6 +58,15 @@ export class RiskInsightsPrototypeOrchestrationService {
private allCiphers: CipherView[] = [];
private passwordUseMap: Map<string, string[]> = new Map();
/** Maps cipher ID to the domains (applications) it belongs to */
private cipherToApplicationsMap = new Map<string, string[]>();
/** Maps domain to the cipher IDs that belong to it */
private applicationToCiphersMap = new Map<string, string[]>();
/** Maps cipher ID to the set of member IDs with access (for at-risk member tracking) */
private cipherToMemberIdsMap = new Map<string, Set<string>>();
// ============================================================================
// Internal Signals (private, writable)
// ============================================================================
@@ -72,6 +88,7 @@ export class RiskInsightsPrototypeOrchestrationService {
// Results
private readonly _items = signal<RiskInsightsItem[]>([]);
private readonly _applications = signal<RiskInsightsApplication[]>([]);
// Error state
private readonly _error = signal<string | null>(null);
@@ -97,6 +114,7 @@ export class RiskInsightsPrototypeOrchestrationService {
// Results
readonly items = this._items.asReadonly();
readonly applications = this._applications.asReadonly();
// Error state
readonly error = this._error.asReadonly();
@@ -197,6 +215,9 @@ export class RiskInsightsPrototypeOrchestrationService {
// Build password use map for reuse detection
this.passwordUseMap = this.riskInsightsService.buildPasswordUseMap(ciphers);
// Build application aggregations
this.buildApplicationAggregations();
}),
// PHASE 2: Run health checks if enabled
switchMap(() => this.runHealthChecksIfEnabled$()),
@@ -240,6 +261,7 @@ export class RiskInsightsPrototypeOrchestrationService {
*/
resetState(): void {
this._items.set([]);
this._applications.set([]);
this._processingPhase.set(ProcessingPhase.Idle);
this._progressMessage.set("");
this._cipherProgress.set({ current: 0, total: 0, percent: 0 });
@@ -250,6 +272,9 @@ export class RiskInsightsPrototypeOrchestrationService {
this.cipherIndexMap.clear();
this.allCiphers = [];
this.passwordUseMap.clear();
this.cipherToApplicationsMap.clear();
this.applicationToCiphersMap.clear();
this.cipherToMemberIdsMap.clear();
}
// ============================================================================
@@ -322,6 +347,9 @@ export class RiskInsightsPrototypeOrchestrationService {
total: totalCiphers,
percent: Math.round((processedCount / totalCiphers) * 100),
});
// Update application at-risk counts after health checks
this.updateApplicationAtRiskCounts();
}),
map((): void => undefined),
);
@@ -370,6 +398,9 @@ export class RiskInsightsPrototypeOrchestrationService {
// Update items with member counts
this.updateItemsWithMemberCounts(progressResult.processedCiphers);
// Update application member counts
this.updateApplicationsWithMemberCounts(progressResult.processedCiphers);
}
}),
last(),
@@ -485,6 +516,9 @@ export class RiskInsightsPrototypeOrchestrationService {
if (hasChanges) {
this._items.set(currentItems);
// Update application at-risk counts after HIBP updates
this.updateApplicationAtRiskCounts();
}
}
@@ -542,4 +576,162 @@ export class RiskInsightsPrototypeOrchestrationService {
this._items.set(currentItems);
}
}
// ============================================================================
// Private Methods - Application Aggregation
// ============================================================================
/**
* Builds application aggregations from the loaded ciphers.
* Called after Phase 1 cipher loading completes.
* Creates an application entry for each unique domain found across all ciphers.
*/
private buildApplicationAggregations(): void {
const applicationMap = new Map<string, RiskInsightsApplication>();
// Clear existing maps
this.cipherToApplicationsMap.clear();
this.applicationToCiphersMap.clear();
for (const cipher of this.allCiphers) {
const domains = getTrimmedCipherUris(cipher);
// Track which applications this cipher belongs to
this.cipherToApplicationsMap.set(cipher.id, domains);
for (const domain of domains) {
// Track which ciphers belong to each application
if (!this.applicationToCiphersMap.has(domain)) {
this.applicationToCiphersMap.set(domain, []);
}
this.applicationToCiphersMap.get(domain)!.push(cipher.id);
// Create or update application entry
if (!applicationMap.has(domain)) {
applicationMap.set(domain, {
domain,
passwordCount: 0,
atRiskPasswordCount: 0,
memberIds: new Set<string>(),
atRiskMemberIds: new Set<string>(),
memberAccessPending: true,
cipherIds: [],
});
}
const app = applicationMap.get(domain)!;
app.passwordCount++;
app.cipherIds.push(cipher.id);
}
}
// Convert to array and sort by password count descending
const applications = Array.from(applicationMap.values()).sort(
(a, b) => b.passwordCount - a.passwordCount,
);
this._applications.set(applications);
}
/**
* Updates application member counts when cipher member data arrives.
* Called incrementally during Phase 3 member loading.
*
* @param processedCiphers - Ciphers with their member access data
*/
private updateApplicationsWithMemberCounts(processedCiphers: CipherWithMemberAccess[]): void {
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>>();
const domainAtRiskMemberMap = new Map<string, Set<string>>();
for (const processed of processedCiphers) {
const cipherId = processed.cipher.id;
const domains = this.cipherToApplicationsMap.get(cipherId) ?? [];
const memberIds = processed.members.map((m) => m.userId);
// 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);
const isAtRisk = item?.status === RiskInsightsItemStatus.AtRisk;
for (const domain of domains) {
// Add all members to domain's member set
if (!domainMemberMap.has(domain)) {
domainMemberMap.set(domain, new Set<string>());
}
for (const memberId of memberIds) {
domainMemberMap.get(domain)!.add(memberId);
}
// If cipher is at-risk, add members to at-risk set
if (isAtRisk) {
if (!domainAtRiskMemberMap.has(domain)) {
domainAtRiskMemberMap.set(domain, new Set<string>());
}
for (const memberId of memberIds) {
domainAtRiskMemberMap.get(domain)!.add(memberId);
}
}
}
}
// Update applications with member data
const updatedApplications: RiskInsightsApplication[] = currentApplications.map((app) => {
const memberIds = domainMemberMap.get(app.domain) ?? app.memberIds;
const atRiskMemberIds = domainAtRiskMemberMap.get(app.domain) ?? app.atRiskMemberIds;
return {
...app,
memberIds: new Set<string>(memberIds),
atRiskMemberIds: new Set<string>(atRiskMemberIds),
memberAccessPending: false,
};
});
this._applications.set(updatedApplications);
}
/**
* Updates application at-risk counts based on current item statuses.
* 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) => {
let atRiskCount = 0;
const atRiskMemberIds = new Set<string>();
for (const cipherId of app.cipherIds) {
const item = itemMap.get(cipherId);
if (item?.status === RiskInsightsItemStatus.AtRisk) {
atRiskCount++;
// Add members of at-risk ciphers to at-risk member set
const cipherMemberIds = this.cipherToMemberIdsMap.get(cipherId);
if (cipherMemberIds) {
for (const memberId of cipherMemberIds) {
atRiskMemberIds.add(memberId);
}
}
}
}
return {
...app,
atRiskPasswordCount: atRiskCount,
atRiskMemberIds,
};
});
this._applications.set(updatedApplications);
}
}

View File

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

View File

@@ -3,7 +3,6 @@ export * from "./api/member-cipher-details-api.service";
export * from "./api/risk-insights-api.service";
export * from "./api/security-tasks-api.service";
export * from "./domain/cipher-access-mapping.service";
export * from "./domain/cipher-health-orchestration.service";
export * from "./domain/critical-apps.service";
export * from "./domain/password-health.service";
export * from "./domain/risk-insights-encryption.service";

View File

@@ -1,6 +1,6 @@
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ProcessingPhase, ProgressInfo, RiskInsightsItem } from "../types";
import { ProcessingPhase, ProgressInfo, RiskInsightsApplication, RiskInsightsItem } from "../types";
/**
* Generic read-only signal interface for framework-agnostic abstraction.
@@ -35,8 +35,9 @@ export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction {
abstract readonly memberProgress: ReadonlySignal<ProgressInfo>;
abstract readonly hibpProgress: ReadonlySignal<ProgressInfo>;
// Results (read-only signal)
// Results (read-only signals)
abstract readonly items: ReadonlySignal<RiskInsightsItem[]>;
abstract readonly applications: ReadonlySignal<RiskInsightsApplication[]>;
// Error state (read-only signal)
abstract readonly error: ReadonlySignal<string | null>;

View File

@@ -36,6 +36,26 @@ export interface ProgressInfo {
percent: number;
}
/**
* Represents an application (domain) aggregation in the Applications tab
*/
export interface RiskInsightsApplication {
/** The trimmed domain (e.g., "amazon.com") */
domain: string;
/** Number of ciphers associated with this application */
passwordCount: number;
/** Number of at-risk ciphers */
atRiskPasswordCount: number;
/** Set of distinct member IDs with access to ANY cipher in this application */
memberIds: Set<string>;
/** Set of distinct member IDs with access to AT-RISK ciphers */
atRiskMemberIds: Set<string>;
/** Whether member data is still being loaded */
memberAccessPending: boolean;
/** IDs of ciphers belonging to this application (for expandable row drill-down) */
cipherIds: string[];
}
/**
* Represents a single item in the risk insights report
*/