diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ac40f78e43f..fa30115b16f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4522,6 +4522,34 @@ "thisMightTakeFewMinutes": { "message": "This might take a few minutes." }, + "fetchingCollections": { + "message": "Fetching collections..." + }, + "fetchingGroups": { + "message": "Fetching groups..." + }, + "fetchingItems": { + "message": "Fetching items..." + }, + "processingData": { + "message": "Processing data..." + }, + "processingMembers": { + "message": "Processing members..." + }, + "processingXOfYMembers": { + "message": "Processing $X$ of $Y$ members", + "placeholders": { + "x": { + "content": "$1", + "example": "5" + }, + "y": { + "content": "$2", + "example": "100" + } + } + }, "riskInsightsRunReport": { "message": "Run report" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-loading.component.ts new file mode 100644 index 00000000000..f861f49f938 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-loading.component.ts @@ -0,0 +1,107 @@ +import { CommonModule } from "@angular/common"; +import { Component, computed, input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ProgressModule } from "@bitwarden/components"; + +import { + MemberAccessProgress, + MemberAccessProgressConfig, + MemberAccessProgressState, + calculateProgressPercentage, +} from "./model/member-access-progress"; + +/** + * Loading component for Member Access Report. + * Displays a progress bar and status messages during report generation. + * + * Follows the pattern established by `dirt-report-loading` in Access Intelligence, + * but supports dynamic progress during member processing. + */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-member-access-loading", + imports: [CommonModule, JslibModule, ProgressModule], + template: ` +
+
+ +
+ +
+ + +
+ + {{ progressMessage() | i18n }} + + @if (showMemberProgress()) { + + {{ "processingXOfYMembers" | i18n: processedCount() : totalCount() }} + + } @else { + + {{ "thisMightTakeFewMinutes" | i18n }} + + } +
+
+
+ `, +}) +export class MemberAccessLoadingComponent { + /** + * Progress state input from parent component. + * Recommended: delay emissions to this input to ensure each step displays for a minimum time. + */ + readonly progressState = input({ + step: MemberAccessProgress.FetchingMembers, + processedMembers: 0, + totalMembers: 0, + message: "", + }); + + /** + * Calculate the progress percentage based on current state. + * For ProcessingMembers step, this is dynamic based on member count. + */ + protected readonly progressPercentage = computed(() => { + return calculateProgressPercentage(this.progressState()); + }); + + /** + * Get the i18n message key for the current progress step. + */ + protected readonly progressMessage = computed(() => { + const state = this.progressState(); + return MemberAccessProgressConfig[state.step].messageKey; + }); + + /** + * Show member processing count only during the ProcessingMembers step. + */ + protected readonly showMemberProgress = computed(() => { + const state = this.progressState(); + return state.step === MemberAccessProgress.ProcessingMembers && state.totalMembers > 0; + }); + + /** + * Get the processed member count for display. + */ + protected readonly processedCount = computed(() => { + return this.progressState().processedMembers.toString(); + }); + + /** + * Get the total member count for display. + */ + protected readonly totalCount = computed(() => { + return this.progressState().totalMembers.toString(); + }); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index 0200e206327..301e7d864a9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -1,21 +1,16 @@ - + @if (!(isLoading$ | async)) { + - + + }
@@ -24,40 +19,54 @@

- +@if (currentProgressStep(); as progressState) { + + +} @else if (isLoading$ | async) { +

{{ "loading" | i18n }}

-
- - - {{ "members" | i18n }} - {{ "groups" | i18n }} - {{ "collections" | i18n }} - {{ "items" | i18n }} - - - -
- -
- +} @else { + + + + {{ "members" | i18n }} + {{ "groups" | i18n }} + + {{ "collections" | i18n }} + + {{ "items" | i18n }} + + + +
+ +
+ -
- {{ row.email }} +
+ {{ row.email }} +
-
- - {{ row.groupsCount }} - {{ row.collectionsCount }} - {{ row.itemsCount }} -
-
+ + {{ row.groupsCount }} + {{ row.collectionsCount }} + {{ row.itemsCount }} + + +} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts index 445cee6683c..6b5c508d11e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts @@ -1,13 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, OnInit } from "@angular/core"; +import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rxjs"; +import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom, skip } from "rxjs"; +import { + CollectionAdminService, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; @@ -19,6 +24,7 @@ import { DialogService, SearchModule, TableDataSource } from "@bitwarden/compone import { KeyService } from "@bitwarden/key-management"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { CoreOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/core"; +import { GroupApiService } from "@bitwarden/web-vault/app/admin-console/organizations/core/services/group/group-api.service"; import { openUserAddEditDialog, MemberDialogResult, @@ -28,23 +34,44 @@ import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils" import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { MemberAccessReportApiService } from "./services/member-access-report-api.service"; +import { MemberAccessLoadingComponent } from "./member-access-loading.component"; +import { MemberAccessProgress, MemberAccessProgressState } from "./model/member-access-progress"; import { MemberAccessReportServiceAbstraction } from "./services/member-access-report.abstraction"; import { MemberAccessReportService } from "./services/member-access-report.service"; import { userReportItemHeaders } from "./view/member-access-export.view"; import { MemberAccessReportView } from "./view/member-access-report.view"; +/** Minimum time (ms) to display each progress step for smooth UX */ +const STEP_DISPLAY_DELAY_MS = 250; + +type ProgressStep = MemberAccessProgressState | null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "member-access-report", templateUrl: "member-access-report.component.html", - imports: [SharedModule, SearchModule, HeaderModule, CoreOrganizationModule], + imports: [ + SharedModule, + SearchModule, + HeaderModule, + CoreOrganizationModule, + MemberAccessLoadingComponent, + ], providers: [ safeProvider({ provide: MemberAccessReportServiceAbstraction, useClass: MemberAccessReportService, - deps: [MemberAccessReportApiService, I18nService, EncryptService, KeyService, AccountService], + deps: [ + I18nService, + EncryptService, + KeyService, + AccountService, + OrganizationUserApiService, + CollectionAdminService, + GroupApiService, + ApiService, + ], }), ], }) @@ -55,6 +82,11 @@ export class MemberAccessReportComponent implements OnInit { protected orgIsOnSecretsManagerStandalone: boolean; protected isLoading$ = new BehaviorSubject(true); + /** Current progress state for the loading component */ + protected readonly currentProgressStep = signal(null); + + private destroyRef = inject(DestroyRef); + constructor( private route: ActivatedRoute, protected reportService: MemberAccessReportService, @@ -68,6 +100,23 @@ export class MemberAccessReportComponent implements OnInit { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); + + // Subscribe to progress updates + // Use simple subscription - the service batches ProcessingMembers updates + this.reportService.progress$ + .pipe( + skip(1), // Skip initial null emission from BehaviorSubject + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((state) => { + if (state?.step === MemberAccessProgress.Complete) { + // Show complete briefly, then hide loading + this.currentProgressStep.set(state); + setTimeout(() => this.currentProgressStep.set(null), STEP_DISPLAY_DELAY_MS); + } else { + this.currentProgressStep.set(state); + } + }); } async ngOnInit() { diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/model/member-access-progress.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/model/member-access-progress.ts new file mode 100644 index 00000000000..07d6844e2c3 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/model/member-access-progress.ts @@ -0,0 +1,79 @@ +/** + * Progress steps for the Member Access Report generation. + * Uses const object pattern per ADR-0025 (no TypeScript enums). + */ +export const MemberAccessProgress = Object.freeze({ + FetchingMembers: 1, + FetchingCollections: 2, + FetchingGroups: 3, + FetchingCipherCounts: 4, + BuildingMaps: 5, + ProcessingMembers: 6, + Complete: 7, +} as const); + +export type MemberAccessProgressStep = + (typeof MemberAccessProgress)[keyof typeof MemberAccessProgress]; + +/** + * State object for tracking progress during report generation. + * Used by the loading component to display progress bar and messages. + */ +export interface MemberAccessProgressState { + /** Current step in the progress workflow */ + step: MemberAccessProgressStep; + /** Number of members processed (relevant during ProcessingMembers step) */ + processedMembers: number; + /** Total number of members to process */ + totalMembers: number; + /** Human-readable message describing current operation */ + message: string; +} + +/** + * Configuration for each progress step including display message and progress percentage. + */ +export const MemberAccessProgressConfig = Object.freeze({ + [MemberAccessProgress.FetchingMembers]: { + messageKey: "fetchingMemberData", + progress: 10, + }, + [MemberAccessProgress.FetchingCollections]: { + messageKey: "fetchingCollections", + progress: 20, + }, + [MemberAccessProgress.FetchingGroups]: { + messageKey: "fetchingGroups", + progress: 25, + }, + [MemberAccessProgress.FetchingCipherCounts]: { + messageKey: "fetchingItems", + progress: 30, + }, + [MemberAccessProgress.BuildingMaps]: { + messageKey: "processingData", + progress: 35, + }, + [MemberAccessProgress.ProcessingMembers]: { + messageKey: "processingMembers", + // Progress is dynamic: 35% + (processed/total * 60%) → ranges from 35% to 95% + progress: 35, + }, + [MemberAccessProgress.Complete]: { + messageKey: "complete", + progress: 100, + }, +} as const); + +/** + * Calculates the progress percentage based on the current state. + * For the ProcessingMembers step, progress is calculated dynamically based on member count. + */ +export function calculateProgressPercentage(state: MemberAccessProgressState): number { + if (state.step === MemberAccessProgress.ProcessingMembers && state.totalMembers > 0) { + // Dynamic: 35% + (processed/total * 60%) → ranges from 35% to 95% + const memberProgress = (state.processedMembers / state.totalMembers) * 60; + return Math.min(95, 35 + memberProgress); + } + return MemberAccessProgressConfig[state.step].progress; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts index cf2f3b6417b..902beb37eee 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts @@ -1,11 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Observable } from "rxjs"; + import { OrganizationId } from "@bitwarden/common/types/guid"; +import { MemberAccessProgressState } from "../model/member-access-progress"; import { MemberAccessExportItem } from "../view/member-access-export.view"; import { MemberAccessReportView } from "../view/member-access-report.view"; export abstract class MemberAccessReportServiceAbstraction { + /** Observable for progress state updates during report generation */ + progress$: Observable; generateMemberAccessReportView: ( organizationId: OrganizationId, ) => Promise; diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index 3bd74391419..e289aeac8be 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -1,154 +1,478 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { BehaviorSubject, firstValueFrom, Observable } from "rxjs"; -import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; +import { + CollectionAccessSelectionView, + CollectionAdminService, + CollectionAdminView, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Guid, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response"; import { KeyService } from "@bitwarden/key-management"; +import { GroupApiService } from "@bitwarden/web-vault/app/admin-console/organizations/core/services/group/group-api.service"; +import { GroupDetailsView } from "@bitwarden/web-vault/app/admin-console/organizations/core/views/group-details.view"; import { getPermissionList, convertToPermission, } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector"; -import { MemberAccessResponse } from "../response/member-access-report.response"; +import { MemberAccessProgress, MemberAccessProgressState } from "../model/member-access-progress"; import { MemberAccessExportItem } from "../view/member-access-export.view"; import { MemberAccessReportView } from "../view/member-access-report.view"; -import { MemberAccessReportApiService } from "./member-access-report-api.service"; +/** + * Internal interface for collection access tracking + */ +interface CollectionAccess { + collectionId: string; + collectionName: string; + readOnly: boolean; + hidePasswords: boolean; + manage: boolean; + /** Group ID if access is via group, null for direct access */ + viaGroupId: string | null; + /** Group name if access is via group, null for direct access */ + viaGroupName: string | null; +} + +/** + * Internal interface for member data from the API + */ +interface MemberData { + id: string; + name: string; + email: string; + twoFactorEnabled: boolean; + resetPasswordEnrolled: boolean; + usesKeyConnector: boolean; + groups: string[]; + avatarColor: string | null; +} + +/** + * Lookup maps for efficient data access during member processing + */ +interface LookupMaps { + /** Map: userId → direct collection access[] */ + userCollectionMap: Map; + /** Map: groupId → collection access[] */ + groupCollectionMap: Map; + /** Map: userId → groupId[] */ + userGroupMap: Map; + /** Map: collectionId → cipher count */ + collectionCipherCountMap: Map; + /** Map: groupId → group name */ + groupNameMap: Map; + /** Map: collectionId → collection name (decrypted) */ + collectionNameMap: Map; +} @Injectable({ providedIn: "root" }) export class MemberAccessReportService { + /** Progress tracking subject for UI updates */ + private progressSubject = new BehaviorSubject(null); + + /** Observable for progress state updates */ + progress$: Observable = this.progressSubject.asObservable(); + + /** Cached lookup maps for export generation */ + private cachedLookupMaps: LookupMaps | null = null; + private cachedMembers: MemberData[] | null = null; + constructor( - private reportApiService: MemberAccessReportApiService, private i18nService: I18nService, private encryptService: EncryptService, private keyService: KeyService, private accountService: AccountService, + private organizationUserApiService: OrganizationUserApiService, + private collectionAdminService: CollectionAdminService, + private groupApiService: GroupApiService, + private apiService: ApiService, ) {} + /** - * Transforms user data into a MemberAccessReportView. + * Generates the Member Access Report using frontend-driven data fetching. + * Makes multiple lightweight API calls instead of a single heavy endpoint. * - * @param {UserData} userData - The user data to aggregate. - * @param {ReportCollection[]} collections - An array of collections, each with an ID and a total number of items. - * @returns {MemberAccessReportView} The aggregated report view. + * @param organizationId - The organization to generate the report for + * @returns Array of aggregated member access views for display */ async generateMemberAccessReportView( organizationId: OrganizationId, ): Promise { - const memberAccessData = await this.reportApiService.getMemberAccessData(organizationId); + // Clear cached data on new report generation + this.cachedLookupMaps = null; + this.cachedMembers = null; - // group member access data by userGuid - const userMap = new Map(); - memberAccessData.forEach((userData) => { - const userGuid = userData.userGuid; - if (!userMap.has(userGuid)) { - userMap.set(userGuid, []); + // Step 1: Fetch members with their group memberships + this.emitProgress(MemberAccessProgress.FetchingMembers, 0, 0); + const members = await this.fetchMembers(organizationId); + + // Step 2: Fetch collections with access details + this.emitProgress(MemberAccessProgress.FetchingCollections, 0, members.length); + const collections = await this.fetchCollections(organizationId); + + // Step 3: Fetch groups with details + this.emitProgress(MemberAccessProgress.FetchingGroups, 0, members.length); + const groups = await this.fetchGroups(organizationId); + + // Step 4: Fetch organization ciphers for counting + this.emitProgress(MemberAccessProgress.FetchingCipherCounts, 0, members.length); + const ciphers = await this.fetchOrganizationCiphers(organizationId); + + // Step 5: Build lookup maps + this.emitProgress(MemberAccessProgress.BuildingMaps, 0, members.length); + const lookupMaps = this.buildLookupMaps(members, collections, groups, ciphers); + + // Cache for export + this.cachedLookupMaps = lookupMaps; + this.cachedMembers = members; + + // Step 6: Process each member with progress tracking + // Batch progress updates to avoid RxJS backpressure (emit every ~2% or minimum every 10 members) + const results: MemberAccessReportView[] = []; + const progressInterval = Math.max(10, Math.floor(members.length / 50)); + + for (let i = 0; i < members.length; i++) { + const member = members[i]; + const view = this.processMemberForView(member, lookupMaps); + results.push(view); + + // Only emit progress at intervals to avoid flooding the UI + if ((i + 1) % progressInterval === 0 || i === members.length - 1) { + this.emitProgress(MemberAccessProgress.ProcessingMembers, i + 1, members.length); } - userMap.get(userGuid)?.push(userData); - }); + } - // aggregate user data - const memberAccessReportViewCollection: MemberAccessReportView[] = []; - userMap.forEach((userDataArray, userGuid) => { - const collectionCount = this.getDistinctCount( - userDataArray.map((data) => data.collectionId).filter((id) => !!id), - ); - const groupCount = this.getDistinctCount( - userDataArray.map((data) => data.groupId).filter((id) => !!id), - ); - const itemsCount = this.getDistinctCount( - userDataArray - .flatMap((data) => data.cipherIds) - .filter((id) => id !== "00000000-0000-0000-0000-000000000000"), - ); - const aggregatedData = { - userGuid: userGuid, - name: userDataArray[0].userName, - email: userDataArray[0].email, - collectionsCount: collectionCount, - groupsCount: groupCount, - itemsCount: itemsCount, - usesKeyConnector: userDataArray.some((data) => data.usesKeyConnector), - }; + // Step 7: Complete + this.emitProgress(MemberAccessProgress.Complete, members.length, members.length); - memberAccessReportViewCollection.push(aggregatedData); - }); - - return memberAccessReportViewCollection; + return results; } + /** + * Generates detailed export items with one row per user-collection-permission combination. + * + * @param organizationId - The organization to generate export for + * @returns Array of export items for CSV generation + */ async generateUserReportExportItems( organizationId: OrganizationId, ): Promise { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const organizationSymmetricKey = await firstValueFrom( - this.keyService.orgKeys$(activeUserId).pipe(map((keys) => keys[organizationId])), - ); + // Use cached data if available, otherwise fetch fresh + let lookupMaps = this.cachedLookupMaps; + let members = this.cachedMembers; - const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId); - const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString); - - const collectionNameMap = new Map( - collectionNames.filter((col) => col !== null).map((col) => [col, ""]), - ); - for await (const key of collectionNameMap.keys()) { - const encryptedCollectionName = new EncString(key); - const collectionName = await this.encryptService.decryptString( - encryptedCollectionName, - organizationSymmetricKey, - ); - collectionNameMap.set(key, collectionName); + if (!lookupMaps || !members) { + // Need to fetch data - this happens if export is called without generating report first + const freshMembers = await this.fetchMembers(organizationId); + const collections = await this.fetchCollections(organizationId); + const groups = await this.fetchGroups(organizationId); + const ciphers = await this.fetchOrganizationCiphers(organizationId); + lookupMaps = this.buildLookupMaps(freshMembers, collections, groups, ciphers); + members = freshMembers; } - const exportItems = memberAccessReports.map((report) => { - const collectionName = collectionNameMap.get(report.collectionName.encryptedString); - return { - email: report.email, - name: report.userName, - twoStepLogin: report.twoFactorEnabled - ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") - : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), - accountRecovery: report.accountRecoveryEnabled - ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") - : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), - group: report.groupName - ? report.groupName - : this.i18nService.t("memberAccessReportNoGroup"), - collection: collectionName - ? collectionName - : this.i18nService.t("memberAccessReportNoCollection"), - collectionPermission: report.collectionId - ? this.getPermissionText(report) - : this.i18nService.t("memberAccessReportNoCollectionPermission"), - totalItems: report.cipherIds - .filter((_) => _ != "00000000-0000-0000-0000-000000000000") - .length.toString(), - }; - }); - return exportItems.flat(); + return this.generateExportData(lookupMaps, members); } - private getPermissionText(accessDetails: MemberAccessResponse): string { + /** + * Emits a progress update to subscribers + */ + private emitProgress( + step: (typeof MemberAccessProgress)[keyof typeof MemberAccessProgress], + processedMembers: number, + totalMembers: number, + ): void { + this.progressSubject.next({ + step, + processedMembers, + totalMembers, + message: "", + }); + } + + /** + * Fetches all organization members with their group memberships + */ + private async fetchMembers(organizationId: OrganizationId): Promise { + const response = await this.organizationUserApiService.getAllUsers(organizationId, { + includeGroups: true, + }); + + return response.data.map((user) => ({ + id: user.id, + name: user.name || "", + email: user.email, + twoFactorEnabled: user.twoFactorEnabled, + resetPasswordEnrolled: user.resetPasswordEnrolled, + usesKeyConnector: user.usesKeyConnector, + groups: user.groups || [], + avatarColor: user.avatarColor || null, + })); + } + + /** + * Fetches all collections with user and group access details + */ + private async fetchCollections(organizationId: OrganizationId): Promise { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return await firstValueFrom( + this.collectionAdminService.collectionAdminViews$(organizationId, activeUserId), + ); + } + + /** + * Fetches all groups with their collection access + */ + private async fetchGroups(organizationId: OrganizationId): Promise { + return await this.groupApiService.getAllDetails(organizationId); + } + + /** + * Fetches all organization ciphers for counting per collection + */ + private async fetchOrganizationCiphers( + organizationId: OrganizationId, + ): Promise { + const response = await this.apiService.getCiphersOrganization(organizationId); + return response.data; + } + + /** + * Builds efficient lookup maps from the fetched data for O(1) access during member processing + */ + private buildLookupMaps( + members: MemberData[], + collections: CollectionAdminView[], + groups: GroupDetailsView[], + ciphers: CipherResponse[], + ): LookupMaps { + const userCollectionMap = new Map(); + const groupCollectionMap = new Map(); + const userGroupMap = new Map(); + const collectionCipherCountMap = new Map(); + const groupNameMap = new Map(); + const collectionNameMap = new Map(); + + // Build collectionCipherCountMap by iterating ciphers and counting per collection + // Each cipher has collectionIds[] - a cipher in 3 collections adds 1 to each collection's count + for (const cipher of ciphers) { + for (const collectionId of cipher.collectionIds || []) { + const currentCount = collectionCipherCountMap.get(collectionId) || 0; + collectionCipherCountMap.set(collectionId, currentCount + 1); + } + } + + // Build groupNameMap + for (const group of groups) { + groupNameMap.set(group.id, group.name); + } + + // Build collectionNameMap and userCollectionMap from collections + for (const collection of collections) { + collectionNameMap.set(collection.id, collection.name); + + // Build userCollectionMap from collections.users + for (const userAccess of collection.users || []) { + const existing = userCollectionMap.get(userAccess.id) || []; + existing.push({ + collectionId: collection.id, + collectionName: collection.name, + readOnly: userAccess.readOnly, + hidePasswords: userAccess.hidePasswords, + manage: userAccess.manage, + viaGroupId: null, + viaGroupName: null, + }); + userCollectionMap.set(userAccess.id, existing); + } + + // Build groupCollectionMap from collections.groups + for (const groupAccess of collection.groups || []) { + const existing = groupCollectionMap.get(groupAccess.id) || []; + existing.push({ + collectionId: collection.id, + collectionName: collection.name, + readOnly: groupAccess.readOnly, + hidePasswords: groupAccess.hidePasswords, + manage: groupAccess.manage, + viaGroupId: groupAccess.id, + viaGroupName: groupNameMap.get(groupAccess.id) || null, + }); + groupCollectionMap.set(groupAccess.id, existing); + } + } + + // Build userGroupMap from members.groups + for (const member of members) { + if (member.groups?.length) { + userGroupMap.set(member.id, member.groups); + } + } + + return { + userCollectionMap, + groupCollectionMap, + userGroupMap, + collectionCipherCountMap, + groupNameMap, + collectionNameMap, + }; + } + + /** + * Processes a single member to calculate their aggregated access for the table view + */ + private processMemberForView(member: MemberData, lookupMaps: LookupMaps): MemberAccessReportView { + const { userCollectionMap, groupCollectionMap, userGroupMap, collectionCipherCountMap } = + lookupMaps; + + // Get direct collection access + const directAccess = userCollectionMap.get(member.id) || []; + + // Get group-based collection access + const memberGroups = userGroupMap.get(member.id) || []; + const groupAccess: CollectionAccess[] = []; + for (const groupId of memberGroups) { + const groupCollections = groupCollectionMap.get(groupId) || []; + groupAccess.push(...groupCollections); + } + + // Get unique collection IDs (direct access takes precedence in case of overlap) + const allCollectionIds = new Set([ + ...directAccess.map((a) => a.collectionId), + ...groupAccess.map((a) => a.collectionId), + ]); + + // Calculate total items by summing cipher counts for all accessible collections + let totalItems = 0; + for (const collectionId of allCollectionIds) { + totalItems += collectionCipherCountMap.get(collectionId) || 0; + } + + return { + userGuid: member.id as Guid, + name: member.name, + email: member.email, + collectionsCount: allCollectionIds.size, + groupsCount: memberGroups.length, + itemsCount: totalItems, + usesKeyConnector: member.usesKeyConnector, + avatarColor: member.avatarColor, + }; + } + + /** + * Generates detailed export data with one row per user-collection access + */ + private generateExportData( + lookupMaps: LookupMaps, + members: MemberData[], + ): MemberAccessExportItem[] { + const { + userCollectionMap, + groupCollectionMap, + userGroupMap, + collectionCipherCountMap, + groupNameMap, + } = lookupMaps; + + const exportItems: MemberAccessExportItem[] = []; + + for (const member of members) { + const directAccess = userCollectionMap.get(member.id) || []; + const memberGroups = userGroupMap.get(member.id) || []; + + // Track which collections have been exported for this member to handle deduplication + const exportedCollections = new Set(); + + // Export direct collection access (group = "No Group") + for (const access of directAccess) { + exportItems.push({ + email: member.email, + name: member.name, + twoStepLogin: member.twoFactorEnabled + ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") + : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), + accountRecovery: member.resetPasswordEnrolled + ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") + : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), + group: this.i18nService.t("memberAccessReportNoGroup"), + collection: access.collectionName || this.i18nService.t("memberAccessReportNoCollection"), + collectionPermission: this.getPermissionText(access), + totalItems: String(collectionCipherCountMap.get(access.collectionId) || 0), + }); + exportedCollections.add(access.collectionId); + } + + // Export group-based collection access + for (const groupId of memberGroups) { + const groupCollections = groupCollectionMap.get(groupId) || []; + const groupName = groupNameMap.get(groupId) || "Unknown Group"; + + for (const access of groupCollections) { + exportItems.push({ + email: member.email, + name: member.name, + twoStepLogin: member.twoFactorEnabled + ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") + : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), + accountRecovery: member.resetPasswordEnrolled + ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") + : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), + group: groupName, + collection: + access.collectionName || this.i18nService.t("memberAccessReportNoCollection"), + collectionPermission: this.getPermissionText(access), + totalItems: String(collectionCipherCountMap.get(access.collectionId) || 0), + }); + } + } + + // If member has no collection access at all, add a single row showing that + if (directAccess.length === 0 && memberGroups.length === 0) { + exportItems.push({ + email: member.email, + name: member.name, + twoStepLogin: member.twoFactorEnabled + ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") + : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), + accountRecovery: member.resetPasswordEnrolled + ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") + : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), + group: this.i18nService.t("memberAccessReportNoGroup"), + collection: this.i18nService.t("memberAccessReportNoCollection"), + collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"), + totalItems: "0", + }); + } + } + + return exportItems; + } + + /** + * Converts collection access permissions to localized display text + */ + private getPermissionText(access: CollectionAccess): string { const permissionList = getPermissionList(); const collectionSelectionView = new CollectionAccessSelectionView({ - id: accessDetails.groupId ?? accessDetails.collectionId, - readOnly: accessDetails.readOnly, - hidePasswords: accessDetails.hidePasswords, - manage: accessDetails.manage, + id: access.collectionId, + readOnly: access.readOnly, + hidePasswords: access.hidePasswords, + manage: access.manage, }); return this.i18nService.t( permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId, ); } - - private getDistinctCount(items: T[]): number { - const uniqueItems = new Set(items); - return uniqueItems.size; - } } diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts index 5412babc0e4..3384c42427c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts @@ -8,4 +8,5 @@ export type MemberAccessReportView = { itemsCount: number; userGuid: Guid; usesKeyConnector: boolean; + avatarColor: string | null; };