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;
};