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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export type MemberAccessReportView = {
|
||||
itemsCount: number;
|
||||
userGuid: Guid;
|
||||
usesKeyConnector: boolean;
|
||||
avatarColor: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user