mirror of
https://github.com/bitwarden/browser
synced 2026-02-25 17:13:24 +00:00
applications in prototype
This commit is contained in:
@@ -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 Collection 2 Parent/Child Collection ..."
|
||||
[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">✓</span>
|
||||
<span *ngIf="!result.success" class="tw-text-danger">✗</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>
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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()],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
ProcessingPhase,
|
||||
ProgressInfo,
|
||||
RiskInsightsItem,
|
||||
RiskInsightsApplication,
|
||||
createRiskInsightsItem,
|
||||
calculateRiskStatus,
|
||||
} from "@bitwarden/common/dirt/reports/risk-insights/types";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user