diff --git a/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.html b/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.html new file mode 100644 index 00000000000..29166f07c4d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.html @@ -0,0 +1,77 @@ + + +

Bulk Collection Seeder

+

+ Create multiple collections at once for testing purposes. Enter one collection name per line. +

+
+ +
+ + Collection Names (one per line) + + Enter collection names, one per line. Use "/" for nested collections (e.g., + "Parent/Child"). + + +
+ + +
+ +
+ +

{{ progressMessage() }}

+
+ +
+

Results

+

+ {{ successCount }} succeeded + , {{ failureCount }} failed +

+ +
+
+ + + {{ result.name }} + - {{ result.error }} +
+
+
+
+
diff --git a/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.ts b/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.ts new file mode 100644 index 00000000000..f23d1fc10b8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/bulk-collection-seeder/bulk-collection-seeder.component.ts @@ -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([]); + 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 { + 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(""); + } +} diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index da34f3a23f1..1ed789fbcaa 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -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()], + }, ], }, { diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts index d69f6ee95e2..9f24f153430 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts @@ -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], }) diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html index 85940f3d154..d92840c2de6 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html @@ -1,6 +1,189 @@ -
-

{{ "applications" | i18n }}

-

- {{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }} -

+
+ + @if (applications().length > 0) { +
+ {{ "totalApplications" | i18n }}: {{ applications().length | number }} +
+ + + + + + + + + + + + + + @for (app of applications(); track app.domain) { + + + + + + + + + + + + + + + + + + + + + + + @if (isExpanded(app.domain)) { + @for (cipher of getCiphersForApplication(app.cipherIds); track cipher.cipherId) { + + + + + + + + + + + + + + + + + } + } + } + +
{{ "application" | i18n }}{{ "passwords" | i18n }}{{ "atRiskPasswords" | i18n }}{{ "members" | i18n }}{{ "atRiskMembers" | i18n }}
+ + +
{{ app.domain }}
+
+ {{ app.passwordCount | number }} + + @if (app.atRiskPasswordCount > 0) { + {{ app.atRiskPasswordCount | number }} + } @else { + 0 + } + + @if (app.memberAccessPending) { + + } @else { + {{ app.memberIds.size | number }} + } + + @if (app.memberAccessPending) { + + } @else if (app.atRiskMemberIds.size > 0) { + {{ app.atRiskMemberIds.size | number }} + } @else { + 0 + } +
+
+
+ {{ cipher.cipherName }} +
+ @if (cipher.cipherSubtitle) { +
+ {{ cipher.cipherSubtitle }} +
+ } +
+
+
+ + @if (cipher.weakPassword === true) { + W + } + + @if (cipher.reusedPassword === true) { + R + } + + @if (cipher.exposedPassword === true) { + E + } + + @if ( + cipher.status === RiskInsightsItemStatus.Healthy && + !cipher.weakPassword && + !cipher.reusedPassword && + !cipher.exposedPassword + ) { + + } + + @if (cipher.status === null) { + + } +
+
+ @if (cipher.memberAccessPending) { + + } @else { + {{ cipher.memberCount | number }} + } + + @if (cipher.status === null) { + + } @else if (cipher.status === RiskInsightsItemStatus.AtRisk) { + {{ "atRisk" | i18n }} + } @else { + {{ "healthy" | i18n }} + } +
+ } @else if (processingPhase() === ProcessingPhase.Idle) { + +
+ +

{{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }}

+
+ }
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts index 37a2280c8de..3339e777f7e 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts @@ -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(); + + /** Row size for virtual scrolling (in pixels) */ + protected readonly ROW_SIZE = 52; + + /** Set of expanded application domains */ + protected readonly expandedApplications = signal(new Set()); + + // ============================================================================ + // 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; + } +} diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html index da91c174d69..d7a5eafb006 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html @@ -1,100 +1,4 @@
- -
-
- - - - - - - - -
- - - -
- - - @if (showProgress()) { -
- -
-
- {{ progressMessage() || "Processing..." }} - {{ getOverallProgress() | number: "1.0-0" }}% -
- -
- - - @if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) { -
-
- - {{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} / - {{ hibpProgress().total }} - - {{ hibpProgress().percent }}% -
- -
- } - - - @if (processingPhase() === ProcessingPhase.Complete) { -
- - {{ "reportComplete" | i18n }} -
- } - - - @if (processingPhase() === ProcessingPhase.Error && error()) { -
- - {{ error() }} -
- } -
- } - @if (items().length > 0) {
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts index e2de535d757..072e11858a4 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts @@ -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 // ============================================================================ diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html index 03429ee2fac..b9b543279b1 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html @@ -7,6 +7,108 @@

+ +
+
+ + + + + + + + +
+ + + +
+ + + @if (showProgress()) { +
+ +
+
+ {{ progressMessage() || "Processing..." }} + {{ getOverallProgress() | number: "1.0-0" }}% +
+ +
+ + + @if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) { +
+
+ + {{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} / + {{ hibpProgress().total }} + + {{ hibpProgress().percent }}% +
+ +
+ } + + + @if (processingPhase() === ProcessingPhase.Complete) { +
+ + {{ "reportComplete" | i18n }} +
+ } + + + @if (processingPhase() === ProcessingPhase.Error && error()) { +
+ + {{ error() }} +
+ } +
+ } +
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts index cda4477631b..c0d3e7d31dc 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts @@ -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 { 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; + } + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts index b26c228751c..6f3b8e09032 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts @@ -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 = new Map(); + /** Maps cipher ID to the domains (applications) it belongs to */ + private cipherToApplicationsMap = new Map(); + + /** Maps domain to the cipher IDs that belong to it */ + private applicationToCiphersMap = new Map(); + + /** Maps cipher ID to the set of member IDs with access (for at-risk member tracking) */ + private cipherToMemberIdsMap = new Map>(); + // ============================================================================ // Internal Signals (private, writable) // ============================================================================ @@ -72,6 +88,7 @@ export class RiskInsightsPrototypeOrchestrationService { // Results private readonly _items = signal([]); + private readonly _applications = signal([]); // Error state private readonly _error = signal(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(); + + // 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(), + atRiskMemberIds: new Set(), + 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 map + const domainMemberMap = new Map>(); + const domainAtRiskMemberMap = new Map>(); + + 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()); + } + 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()); + } + 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(memberIds), + atRiskMemberIds: new Set(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(); + + 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); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts index d3f1ab366fa..25942e11ffc 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts @@ -7,6 +7,7 @@ export { ProcessingPhase, ProgressInfo, RiskInsightsItem, + RiskInsightsApplication, createRiskInsightsItem, calculateRiskStatus, } from "@bitwarden/common/dirt/reports/risk-insights/types"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index 4fddb5a2011..41e8ea5188b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -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"; diff --git a/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts index ef002edc538..d6f26e883ca 100644 --- a/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts +++ b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts @@ -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; abstract readonly hibpProgress: ReadonlySignal; - // Results (read-only signal) + // Results (read-only signals) abstract readonly items: ReadonlySignal; + abstract readonly applications: ReadonlySignal; // Error state (read-only signal) abstract readonly error: ReadonlySignal; diff --git a/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts index 5813239a438..03edaa0f589 100644 --- a/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts +++ b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts @@ -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; + /** Set of distinct member IDs with access to AT-RISK ciphers */ + atRiskMemberIds: Set; + /** 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 */