1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 19:23:19 +00:00

add a progress bar and change the approach on how we gather data for the report.

This commit is contained in:
Graham Walker
2026-01-23 13:52:33 -06:00
parent 39bc4fb789
commit 1e5245094d
8 changed files with 749 additions and 147 deletions

View File

@@ -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"
},

View File

@@ -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: `
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[60vh]">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<!-- Progress bar -->
<div class="tw-w-64" role="progressbar" [attr.aria-label]="'loadingProgress' | i18n">
<bit-progress
[barWidth]="progressPercentage()"
[showText]="false"
size="default"
bgColor="primary"
></bit-progress>
</div>
<!-- Status message and subtitle -->
<div class="tw-text-center tw-flex tw-flex-col tw-gap-1">
<span class="tw-text-main tw-text-base tw-font-medium tw-leading-4">
{{ progressMessage() | i18n }}
</span>
@if (showMemberProgress()) {
<span class="tw-text-muted tw-text-sm tw-font-normal tw-leading-4">
{{ "processingXOfYMembers" | i18n: processedCount() : totalCount() }}
</span>
} @else {
<span class="tw-text-muted tw-text-sm tw-font-normal tw-leading-4">
{{ "thisMightTakeFewMinutes" | i18n }}
</span>
}
</div>
</div>
</div>
`,
})
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<MemberAccessProgressState>({
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();
});
}

View File

@@ -1,21 +1,16 @@
<app-header>
<bit-search
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
class="tw-grow"
*ngIf="!(isLoading$ | async)"
></bit-search>
@if (!(isLoading$ | async)) {
<bit-search
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
class="tw-grow"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
[bitAction]="exportReportAction"
*ngIf="!(isLoading$ | async)"
>
<span>{{ "export" | i18n }}</span>
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
</button>
<button type="button" bitButton buttonType="primary" [bitAction]="exportReportAction">
<span>{{ "export" | i18n }}</span>
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
</button>
}
</app-header>
<div class="tw-max-w-4xl">
@@ -24,40 +19,54 @@
</p>
</div>
<ng-container *ngIf="isLoading$ | async">
@if (currentProgressStep(); as progressState) {
<!-- Show progress loading component during data generation -->
<app-member-access-loading [progressState]="progressState"></app-member-access-loading>
} @else if (isLoading$ | async) {
<!-- Fallback loading state (before progress tracking starts) -->
<div class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-5 tw-mt-4">
<i
class="bwi bwi-2x bwi-spinner bwi-spin tw-text-primary-600"
title="{{ 'loading' | i18n }}"
[attr.title]="'loading' | i18n"
aria-hidden="true"
></i>
<h2 bitTypography="h1">{{ "loading" | i18n }}</h2>
</div>
</ng-container>
<bit-table-scroll *ngIf="!(isLoading$ | async)" [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell bitSortable="email" default>{{ "members" | i18n }}</th>
<th bitCell bitSortable="groupsCount" class="tw-w-[278px]">{{ "groups" | i18n }}</th>
<th bitCell bitSortable="collectionsCount" class="tw-w-[278px]">{{ "collections" | i18n }}</th>
<th bitCell bitSortable="itemsCount" class="tw-w-[278px]">{{ "items" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar size="small" [text]="row.name" class="tw-mr-3"></bit-avatar>
<div class="tw-flex tw-flex-col">
<button type="button" bitLink (click)="edit(row)">
{{ row.name }}
</button>
} @else {
<!-- Data table -->
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell bitSortable="email" default>{{ "members" | i18n }}</th>
<th bitCell bitSortable="groupsCount" class="tw-w-[278px]">{{ "groups" | i18n }}</th>
<th bitCell bitSortable="collectionsCount" class="tw-w-[278px]">
{{ "collections" | i18n }}
</th>
<th bitCell bitSortable="itemsCount" class="tw-w-[278px]">{{ "items" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="row | userName"
[id]="row.userGuid"
[color]="row.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<button type="button" bitLink (click)="edit(row)">
{{ row.name }}
</button>
<div class="tw-text-sm tw-mt-1 tw-text-muted">
{{ row.email }}
<div class="tw-text-sm tw-mt-1 tw-text-muted">
{{ row.email }}
</div>
</div>
</div>
</div>
</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.groupsCount }}</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.collectionsCount }}</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.itemsCount }}</td>
</ng-template>
</bit-table-scroll>
</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.groupsCount }}</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.collectionsCount }}</td>
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.itemsCount }}</td>
</ng-template>
</bit-table-scroll>
}

View File

@@ -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<ProgressStep>(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() {

View File

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

View File

@@ -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<MemberAccessProgressState | null>;
generateMemberAccessReportView: (
organizationId: OrganizationId,
) => Promise<MemberAccessReportView[]>;

View File

@@ -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<string, CollectionAccess[]>;
/** Map: groupId → collection access[] */
groupCollectionMap: Map<string, CollectionAccess[]>;
/** Map: userId → groupId[] */
userGroupMap: Map<string, string[]>;
/** Map: collectionId → cipher count */
collectionCipherCountMap: Map<string, number>;
/** Map: groupId → group name */
groupNameMap: Map<string, string>;
/** Map: collectionId → collection name (decrypted) */
collectionNameMap: Map<string, string>;
}
@Injectable({ providedIn: "root" })
export class MemberAccessReportService {
/** Progress tracking subject for UI updates */
private progressSubject = new BehaviorSubject<MemberAccessProgressState | null>(null);
/** Observable for progress state updates */
progress$: Observable<MemberAccessProgressState | null> = 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<MemberAccessReportView[]> {
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<Guid, MemberAccessResponse[]>();
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<string>(
userDataArray.map((data) => data.collectionId).filter((id) => !!id),
);
const groupCount = this.getDistinctCount<string>(
userDataArray.map((data) => data.groupId).filter((id) => !!id),
);
const itemsCount = this.getDistinctCount<Guid>(
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<MemberAccessExportItem[]> {
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<MemberData[]> {
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<CollectionAdminView[]> {
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<GroupDetailsView[]> {
return await this.groupApiService.getAllDetails(organizationId);
}
/**
* Fetches all organization ciphers for counting per collection
*/
private async fetchOrganizationCiphers(
organizationId: OrganizationId,
): Promise<CipherResponse[]> {
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<string, CollectionAccess[]>();
const groupCollectionMap = new Map<string, CollectionAccess[]>();
const userGroupMap = new Map<string, string[]>();
const collectionCipherCountMap = new Map<string, number>();
const groupNameMap = new Map<string, string>();
const collectionNameMap = new Map<string, string>();
// 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<string>();
// 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<T>(items: T[]): number {
const uniqueItems = new Set(items);
return uniqueItems.size;
}
}

View File

@@ -8,4 +8,5 @@ export type MemberAccessReportView = {
itemsCount: number;
userGuid: Guid;
usesKeyConnector: boolean;
avatarColor: string | null;
};