mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
[PM-26487][PM-20112] Member Access Report - Member Cipher Client Mapping (#18774)
* Added v2 version of member access reports that aggregate data client side instead of using endpoint that times out. Added feature flag. * Remove feature flag * Added avatar color to the member access report * Update icon usage * Add story book for member access report * Add icon module to member access report component * Fix test case * Update member access report service to match export of v1 version. Update test cases * Fix billing error in member access report * Add timeout to fetch organization ciphers * Handle group naming * Add cached permission text * Add memberAccessReportLoadError message * Fix member cipher mapping to deduplicate data in memory * Update log * Update storybook with deterministic data and test type * Fix avatar color default * Fix types * Address timeout cleanup
This commit is contained in:
@@ -10977,6 +10977,9 @@
|
||||
"memberAccessReportAuthenticationEnabledFalse": {
|
||||
"message": "Off"
|
||||
},
|
||||
"memberAccessReportLoadError": {
|
||||
"message": "Failed to load the member access report. This may be due to a large organization size or network issue. Please try again or contact support if the problem persists."
|
||||
},
|
||||
"kdfIterationRecommends": {
|
||||
"message": "We recommend 600,000 or more"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
></bit-search>
|
||||
<button type="button" bitButton buttonType="primary" [bitAction]="exportReportAction">
|
||||
<span>{{ "export" | i18n }}</span>
|
||||
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-sign-in" class="bwi-fw" aria-hidden="true"></bit-icon>
|
||||
</button>
|
||||
}
|
||||
</app-header>
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
@if (isLoading) {
|
||||
<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 }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-2x bwi-spin tw-text-primary-600"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
<h2 bitTypography="h1">{{ "loading" | i18n }}</h2>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -42,7 +42,13 @@
|
||||
<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>
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="row.name"
|
||||
[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 }}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import {
|
||||
applicationConfig,
|
||||
componentWrapperDecorator,
|
||||
Meta,
|
||||
moduleMetadata,
|
||||
StoryObj,
|
||||
} from "@storybook/angular";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import {
|
||||
BillingAccountProfileStateService,
|
||||
BillingApiServiceAbstraction,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { Guid, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { DialogService, ScrollLayoutHostDirective, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/core/tests";
|
||||
|
||||
import { MemberAccessReportComponent } from "./member-access-report.component";
|
||||
import { MemberAccessReportApiService } from "./services/member-access-report-api.service";
|
||||
import { MemberAccessReportService } from "./services/member-access-report.service";
|
||||
import { MemberAccessReportView } from "./view/member-access-report.view";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
function createMockMember(index: number): MemberAccessReportView {
|
||||
const names = ["Alice Johnson", "Bob Smith", "Carol Williams", "David Brown", "Eve Martinez"];
|
||||
const colors = ["#175ddc", "#7c5cdb", "#c93d63", "#d1860a", "#178d5c"];
|
||||
|
||||
return {
|
||||
userGuid: `user-${index}` as Guid,
|
||||
name: names[index % names.length] || `User ${index}`,
|
||||
email: `user${index}@example.com`,
|
||||
avatarColor: colors[index % colors.length],
|
||||
collectionsCount: ((index * 3) % 10) + 1, // Deterministic: 1-10
|
||||
groupsCount: ((index * 2) % 5) + 1, // Deterministic: 1-5
|
||||
itemsCount: ((index * 17) % 200) + 1, // Deterministic: 1-200
|
||||
usesKeyConnector: index % 2 === 0, // Deterministic: alternating true/false
|
||||
};
|
||||
}
|
||||
|
||||
const mockMemberData: MemberAccessReportView[] = [...Array(5).keys()].map(createMockMember);
|
||||
const mockOrganizationId = "org-123" as OrganizationId;
|
||||
|
||||
// ============================================================================
|
||||
// Mock Service Classes
|
||||
// ============================================================================
|
||||
|
||||
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
getApplicationVersion = () => Promise.resolve("2024.1.0");
|
||||
getClientType = () => ClientType.Web;
|
||||
isSelfHost = () => false;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "DIRT/Reports/Member Access Report",
|
||||
component: MemberAccessReportComponent,
|
||||
decorators: [
|
||||
componentWrapperDecorator(
|
||||
(story) =>
|
||||
`<div bitScrollLayoutHost class="tw-flex tw-flex-col tw-h-screen tw-p-6 tw-overflow-auto">${story}</div>`,
|
||||
),
|
||||
moduleMetadata({
|
||||
imports: [ScrollLayoutHostDirective],
|
||||
providers: [],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
// I18n and Routing
|
||||
importProvidersFrom(PreloadedEnglishI18nModule),
|
||||
importProvidersFrom(RouterModule.forRoot([], { useHash: true })),
|
||||
|
||||
// Platform Services
|
||||
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
||||
{ provide: LogService, useValue: { error: () => {}, warning: () => {}, info: () => {} } },
|
||||
{ provide: MessagingService, useValue: { send: () => {} } },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { getFeatureFlag$: () => of(false), serverConfig$: of({}) },
|
||||
},
|
||||
|
||||
// Member Access Report Services
|
||||
{
|
||||
provide: MemberAccessReportService,
|
||||
useValue: {
|
||||
generateMemberAccessReportViewV2: () => Promise.resolve(mockMemberData),
|
||||
generateUserReportExportItemsV2: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
{ provide: MemberAccessReportApiService, useValue: {} },
|
||||
|
||||
// File and Dialog Services
|
||||
{ provide: FileDownloadService, useValue: { download: () => {} } },
|
||||
{ provide: DialogService, useValue: { open: () => ({ closed: of(null) }) } },
|
||||
{ provide: ToastService, useValue: { showToast: () => {} } },
|
||||
{
|
||||
provide: UserNamePipe,
|
||||
useValue: {
|
||||
transform: (user: { name?: string; email?: string }) => user.name || user.email,
|
||||
},
|
||||
},
|
||||
|
||||
// Billing Services
|
||||
{ provide: BillingApiServiceAbstraction, useValue: {} },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: { hasPremiumFromAnySource$: () => of(false) },
|
||||
},
|
||||
{
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useValue: { getOrganizationMetadata$: () => of({ isOnSecretsManagerStandalone: false }) },
|
||||
},
|
||||
|
||||
// Encryption and Key Services
|
||||
{ provide: EncryptService, useValue: {} },
|
||||
{ provide: KeyService, useValue: {} },
|
||||
|
||||
// Admin Console Services
|
||||
{ provide: CollectionAdminService, useValue: {} },
|
||||
{ provide: OrganizationUserApiService, useValue: {} },
|
||||
{ provide: OrganizationService, useValue: { organizations$: () => of([]) } },
|
||||
{ provide: PolicyService, useValue: { policyAppliesToUser$: () => of(false) } },
|
||||
{ provide: ProviderService, useValue: { providers$: () => of([]) } },
|
||||
|
||||
// Vault Services
|
||||
{ provide: CipherService, useValue: {} },
|
||||
{
|
||||
provide: VaultTimeoutSettingsService,
|
||||
useValue: {
|
||||
availableVaultTimeoutActions$: () =>
|
||||
new BehaviorSubject([VaultTimeoutAction.Lock]).asObservable(),
|
||||
},
|
||||
},
|
||||
|
||||
// State and Account Services
|
||||
{
|
||||
provide: StateService,
|
||||
useValue: {
|
||||
activeAccount$: of("account-123"),
|
||||
accounts$: of({ "account-123": { profile: { name: "Test User" } } }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "account-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ provide: AvatarService, useValue: { avatarColor$: of("#175ddc") } },
|
||||
{ provide: SyncService, useValue: { getLastSync: () => Promise.resolve(new Date()) } },
|
||||
|
||||
// Router
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ organizationId: mockOrganizationId }),
|
||||
queryParams: of({}),
|
||||
data: of({ titleId: "memberAccessReport" }), // Provides title for app-header
|
||||
fragment: of(null),
|
||||
url: of([]),
|
||||
paramMap: of({
|
||||
get: (key: string): string | null =>
|
||||
key === "organizationId" ? mockOrganizationId : null,
|
||||
has: (key: string): boolean => key === "organizationId",
|
||||
keys: ["organizationId"],
|
||||
}),
|
||||
queryParamMap: of({
|
||||
get: (): string | null => null,
|
||||
has: (): boolean => false,
|
||||
keys: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta<MemberAccessReportComponent>;
|
||||
|
||||
type Story = StoryObj<MemberAccessReportComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: MemberAccessReportService,
|
||||
useValue: {
|
||||
generateMemberAccessReportViewV2: () =>
|
||||
new Promise(() => {
|
||||
/* Never resolves to show loading state */
|
||||
}),
|
||||
generateUserReportExportItemsV2: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: MemberAccessReportService,
|
||||
useValue: {
|
||||
generateMemberAccessReportViewV2: () => Promise.resolve([]),
|
||||
generateUserReportExportItemsV2: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const WithManyMembers: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: MemberAccessReportService,
|
||||
useValue: {
|
||||
generateMemberAccessReportViewV2: () => {
|
||||
const members = [...Array(50).keys()].map(createMockMember);
|
||||
return Promise.resolve(members);
|
||||
},
|
||||
generateUserReportExportItemsV2: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,10 @@ import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -14,11 +18,22 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, SearchModule, TableDataSource } from "@bitwarden/components";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
DialogService,
|
||||
SearchModule,
|
||||
TableDataSource,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
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 {
|
||||
CoreOrganizationModule,
|
||||
GroupApiService,
|
||||
} from "@bitwarden/web-vault/app/admin-console/organizations/core";
|
||||
import {
|
||||
openUserAddEditDialog,
|
||||
MemberDialogResult,
|
||||
@@ -39,12 +54,24 @@ import { MemberAccessReportView } from "./view/member-access-report.view";
|
||||
@Component({
|
||||
selector: "member-access-report",
|
||||
templateUrl: "member-access-report.component.html",
|
||||
imports: [SharedModule, SearchModule, HeaderModule, CoreOrganizationModule],
|
||||
imports: [SharedModule, SearchModule, HeaderModule, CoreOrganizationModule, IconModule],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: MemberAccessReportServiceAbstraction,
|
||||
useClass: MemberAccessReportService,
|
||||
deps: [MemberAccessReportApiService, I18nService, EncryptService, KeyService, AccountService],
|
||||
deps: [
|
||||
MemberAccessReportApiService,
|
||||
I18nService,
|
||||
EncryptService,
|
||||
KeyService,
|
||||
AccountService,
|
||||
// V2 dependencies
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
CipherService,
|
||||
LogService,
|
||||
GroupApiService,
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -63,6 +90,9 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
protected userNamePipe: UserNamePipe,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
// Connect the search input to the table dataSource filter input
|
||||
this.searchControl.valueChanges
|
||||
@@ -73,33 +103,63 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
async ngOnInit() {
|
||||
this.isLoading$.next(true);
|
||||
|
||||
const params = await firstValueFrom(this.route.params);
|
||||
this.organizationId = params.organizationId;
|
||||
try {
|
||||
const params = await firstValueFrom(this.route.params);
|
||||
this.organizationId = params.organizationId;
|
||||
|
||||
const billingMetadata = await firstValueFrom(
|
||||
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
|
||||
);
|
||||
// Handle billing metadata with fallback
|
||||
try {
|
||||
const billingMetadata = await firstValueFrom(
|
||||
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
|
||||
);
|
||||
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
|
||||
} catch (billingError: unknown) {
|
||||
// Log but don't block - billing metadata is not critical for report
|
||||
this.logService.warning(
|
||||
"[MemberAccessReportComponent] Failed to load billing metadata, using defaults",
|
||||
billingError,
|
||||
);
|
||||
this.orgIsOnSecretsManagerStandalone = false;
|
||||
}
|
||||
|
||||
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
|
||||
|
||||
await this.load();
|
||||
|
||||
this.isLoading$.next(false);
|
||||
await this.load();
|
||||
} catch (error: unknown) {
|
||||
this.logService.error(
|
||||
"[MemberAccessReportComponent] Failed to load member access report",
|
||||
error,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("memberAccessReportLoadError"),
|
||||
});
|
||||
// Set empty data so table doesn't break
|
||||
this.dataSource.data = [];
|
||||
} finally {
|
||||
this.isLoading$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.dataSource.data = await this.reportService.generateMemberAccessReportView(
|
||||
this.organizationId,
|
||||
);
|
||||
try {
|
||||
const reportData = await this.reportService.generateMemberAccessReportViewV2(
|
||||
this.organizationId,
|
||||
);
|
||||
this.dataSource.data = reportData;
|
||||
} catch (error) {
|
||||
this.logService.error("[MemberAccessReportComponent] Report generation failed", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
exportReportAction = async (): Promise<void> => {
|
||||
const exportItems = await this.reportService.generateUserReportExportItemsV2(
|
||||
this.organizationId,
|
||||
);
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: ExportHelper.getFileName("member-access"),
|
||||
blobData: exportToCSV(
|
||||
await this.reportService.generateUserReportExportItems(this.organizationId),
|
||||
userReportItemHeaders,
|
||||
),
|
||||
blobData: exportToCSV(exportItems, userReportItemHeaders),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
GroupApiService,
|
||||
GroupView,
|
||||
} from "@bitwarden/web-vault/app/admin-console/organizations/core";
|
||||
|
||||
import { MemberAccessReportApiService } from "./member-access-report-api.service";
|
||||
import {
|
||||
@@ -16,13 +30,18 @@ import {
|
||||
} from "./member-access-report.mock";
|
||||
import { MemberAccessReportService } from "./member-access-report.service";
|
||||
|
||||
describe("ImportService", () => {
|
||||
describe("MemberAccessReportService", () => {
|
||||
const mockOrganizationId = "mockOrgId" as OrganizationId;
|
||||
const reportApiService = mock<MemberAccessReportApiService>();
|
||||
const mockEncryptService = mock<EncryptService>();
|
||||
const userId = newGuid() as UserId;
|
||||
const mockAccountService = mockAccountServiceWith(userId);
|
||||
const mockKeyService = mock<KeyService>();
|
||||
const mockCollectionAdminService = mock<CollectionAdminService>();
|
||||
const mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
const mockCipherService = mock<CipherService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockGroupApiService = mock<GroupApiService>();
|
||||
let memberAccessReportService: MemberAccessReportService;
|
||||
const i18nMock = mock<I18nService>({
|
||||
t(key) {
|
||||
@@ -37,15 +56,159 @@ describe("ImportService", () => {
|
||||
reportApiService.getMemberAccessData.mockImplementation(() =>
|
||||
Promise.resolve(memberAccessReportsMock),
|
||||
);
|
||||
// Default: mock groups as empty array (tests can override)
|
||||
mockGroupApiService.getAll.mockResolvedValue([]);
|
||||
memberAccessReportService = new MemberAccessReportService(
|
||||
reportApiService,
|
||||
i18nMock,
|
||||
mockEncryptService,
|
||||
mockKeyService,
|
||||
mockAccountService,
|
||||
mockCollectionAdminService,
|
||||
mockOrganizationUserApiService,
|
||||
mockCipherService,
|
||||
mockLogService,
|
||||
mockGroupApiService,
|
||||
);
|
||||
});
|
||||
|
||||
// Helper functions to create properly typed test data
|
||||
const createMockCollection = (
|
||||
id: string,
|
||||
name: string,
|
||||
users: Array<{ id: string; readOnly: boolean; hidePasswords: boolean; manage: boolean }> = [],
|
||||
groups: Array<{ id: string; readOnly: boolean; hidePasswords: boolean; manage: boolean }> = [],
|
||||
): Partial<CollectionAdminView> =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
users,
|
||||
groups,
|
||||
}) as Partial<CollectionAdminView>;
|
||||
|
||||
const createMockOrganizationUser = (
|
||||
id: string,
|
||||
email: string,
|
||||
name: string | null | undefined,
|
||||
options: {
|
||||
twoFactorEnabled?: boolean;
|
||||
usesKeyConnector?: boolean;
|
||||
resetPasswordEnrolled?: boolean;
|
||||
groups?: string[];
|
||||
avatarColor?: string;
|
||||
} = {},
|
||||
): Partial<OrganizationUserUserDetailsResponse> => ({
|
||||
id,
|
||||
email,
|
||||
name: name ?? undefined, // Convert null to undefined to match expected type
|
||||
twoFactorEnabled: options.twoFactorEnabled ?? false,
|
||||
usesKeyConnector: options.usesKeyConnector ?? false,
|
||||
resetPasswordEnrolled: options.resetPasswordEnrolled ?? false,
|
||||
groups: options.groups ?? [],
|
||||
avatarColor: options.avatarColor,
|
||||
});
|
||||
|
||||
const createMockCipher = (id: string, collectionIds: string[]): Partial<CipherView> => ({
|
||||
id,
|
||||
collectionIds,
|
||||
});
|
||||
|
||||
const createMockGroup = (id: string, name: string): GroupView => {
|
||||
const group = new GroupView();
|
||||
group.id = id;
|
||||
group.organizationId = mockOrganizationId;
|
||||
group.name = name;
|
||||
group.externalId = "";
|
||||
return group;
|
||||
};
|
||||
|
||||
// Scenario helpers to reduce test duplication
|
||||
const setupSingleUserWithDirectAccess = (
|
||||
userId: string,
|
||||
collectionId: string,
|
||||
cipherIds: string[],
|
||||
userOptions: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
usesKeyConnector?: boolean;
|
||||
resetPasswordEnrolled?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(collectionId, "Test Collection", [
|
||||
{ id: userId, readOnly: false, hidePasswords: false, manage: false },
|
||||
]),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(
|
||||
userId,
|
||||
userOptions.email ?? "user@test.com",
|
||||
userOptions.name ?? "User",
|
||||
{
|
||||
twoFactorEnabled: userOptions.twoFactorEnabled ?? false,
|
||||
usesKeyConnector: userOptions.usesKeyConnector ?? false,
|
||||
resetPasswordEnrolled: userOptions.resetPasswordEnrolled ?? false,
|
||||
groups: [],
|
||||
},
|
||||
),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue(
|
||||
cipherIds.map((id) => createMockCipher(id, [collectionId])) as CipherView[],
|
||||
);
|
||||
};
|
||||
|
||||
const setupUserWithGroupAccess = (
|
||||
userId: string,
|
||||
groupId: string,
|
||||
collectionId: string,
|
||||
cipherIds: string[],
|
||||
userOptions: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
groupName?: string;
|
||||
} = {},
|
||||
) => {
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId,
|
||||
"Group Collection",
|
||||
[],
|
||||
[{ id: groupId, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(
|
||||
userId,
|
||||
userOptions.email ?? "user@test.com",
|
||||
userOptions.name ?? "User",
|
||||
{
|
||||
groups: [groupId],
|
||||
},
|
||||
),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock group data with actual group name
|
||||
mockGroupApiService.getAll.mockResolvedValue([
|
||||
createMockGroup(groupId, userOptions.groupName ?? "Test Group"),
|
||||
]);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue(
|
||||
cipherIds.map((id) => createMockCipher(id, [collectionId])) as CipherView[],
|
||||
);
|
||||
};
|
||||
|
||||
describe("generateMemberAccessReportView", () => {
|
||||
it("should generate member access report view", async () => {
|
||||
const result =
|
||||
@@ -55,6 +218,7 @@ describe("ImportService", () => {
|
||||
{
|
||||
name: "Sarah Johnson",
|
||||
email: "sjohnson@email.com",
|
||||
avatarColor: "",
|
||||
collectionsCount: 3,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
@@ -64,6 +228,7 @@ describe("ImportService", () => {
|
||||
{
|
||||
name: "James Lull",
|
||||
email: "jlull@email.com",
|
||||
avatarColor: "",
|
||||
collectionsCount: 2,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
@@ -73,6 +238,7 @@ describe("ImportService", () => {
|
||||
{
|
||||
name: "Beth Williams",
|
||||
email: "bwilliams@email.com",
|
||||
avatarColor: "",
|
||||
collectionsCount: 2,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
@@ -82,6 +248,7 @@ describe("ImportService", () => {
|
||||
{
|
||||
name: "Ray Williams",
|
||||
email: "rwilliams@email.com",
|
||||
avatarColor: "",
|
||||
collectionsCount: 3,
|
||||
groupsCount: 3,
|
||||
itemsCount: 0,
|
||||
@@ -165,4 +332,505 @@ describe("ImportService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMemberAccessReportViewV2", () => {
|
||||
it("should generate report using frontend mapping with direct user access", async () => {
|
||||
const userId1 = "user-1";
|
||||
const userId2 = "user-2";
|
||||
const collectionId1 = "collection-1";
|
||||
const cipherId1 = "cipher-1";
|
||||
|
||||
// Mock collections with direct user access
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId1,
|
||||
"Test Collection",
|
||||
[
|
||||
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
|
||||
{ id: userId2, readOnly: true, hidePasswords: true, manage: false },
|
||||
],
|
||||
[],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
// Mock organization users
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [],
|
||||
}),
|
||||
createMockOrganizationUser(userId2, "user2@test.com", "User Two", {
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: true,
|
||||
resetPasswordEnrolled: false,
|
||||
groups: [],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock ciphers
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(cipherId1, [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
collectionsCount: 1,
|
||||
groupsCount: 0,
|
||||
itemsCount: 1,
|
||||
usesKeyConnector: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "user2@test.com",
|
||||
name: "User Two",
|
||||
collectionsCount: 1,
|
||||
groupsCount: 0,
|
||||
itemsCount: 1,
|
||||
usesKeyConnector: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle group-based access correctly", async () => {
|
||||
const userId1 = "user-1";
|
||||
const groupId1 = "group-1";
|
||||
const collectionId1 = "collection-1";
|
||||
const cipherId1 = "cipher-1";
|
||||
|
||||
// Mock collections with group access
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId1,
|
||||
"Group Collection",
|
||||
[],
|
||||
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
// Mock organization users with group membership
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [groupId1],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock groups with actual group name
|
||||
mockGroupApiService.getAll.mockResolvedValue([createMockGroup(groupId1, "Test Group")]);
|
||||
|
||||
// Mock ciphers
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(cipherId1, [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
collectionsCount: 1,
|
||||
groupsCount: 1,
|
||||
itemsCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should aggregate multiple ciphers and collections correctly", async () => {
|
||||
const userId1 = "user-1";
|
||||
const collectionId1 = "collection-1";
|
||||
const collectionId2 = "collection-2";
|
||||
const cipherId1 = "cipher-1";
|
||||
const cipherId2 = "cipher-2";
|
||||
const cipherId3 = "cipher-3";
|
||||
|
||||
// Mock collections
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(collectionId1, "Collection 1", [
|
||||
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
|
||||
]),
|
||||
createMockCollection(collectionId2, "Collection 2", [
|
||||
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
|
||||
]),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
// Mock organization users
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock ciphers - user has access via 2 collections
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(cipherId1, [collectionId1]),
|
||||
createMockCipher(cipherId2, [collectionId1, collectionId2]),
|
||||
createMockCipher(cipherId3, [collectionId2]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "user1@test.com",
|
||||
collectionsCount: 2, // Distinct collections
|
||||
groupsCount: 0,
|
||||
itemsCount: 3, // Distinct ciphers
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle users with no access correctly", async () => {
|
||||
const userId1 = "user-1";
|
||||
const collectionId1 = "collection-1";
|
||||
|
||||
// Mock collection with no user assignments
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([createMockCollection(collectionId1, "Empty Collection")] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
// Mock organization users (user exists but has no access)
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock ciphers
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher("cipher-1", [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
|
||||
|
||||
// User has no access, so shouldn't appear in report
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should use email as name fallback when name is not available", async () => {
|
||||
const userId1 = "user-1";
|
||||
const collectionId1 = "collection-1";
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(collectionId1, "Test Collection", [
|
||||
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
|
||||
]),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
// User without name
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", null, {
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: false,
|
||||
groups: [],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher("cipher-1", [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
|
||||
|
||||
expect(result[0].name).toBe("user1@test.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUserReportExportItemsV2", () => {
|
||||
it("should generate export items with all metadata fields", async () => {
|
||||
setupSingleUserWithDirectAccess("user-1", "collection-1", ["cipher-1"], {
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
twoFactorEnabled: true,
|
||||
resetPasswordEnrolled: true,
|
||||
});
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
|
||||
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
|
||||
collection: "Test Collection",
|
||||
totalItems: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include group information in export when access is via group", async () => {
|
||||
setupUserWithGroupAccess("user-1", "group-1", "collection-1", ["cipher-1"], {
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
groupName: "Engineering Team",
|
||||
});
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// Group name should be populated from API
|
||||
expect(result[0].group).toBe("Engineering Team");
|
||||
});
|
||||
|
||||
it("should group multiple ciphers and count totalItems correctly", async () => {
|
||||
setupSingleUserWithDirectAccess(
|
||||
"user-1",
|
||||
"collection-1",
|
||||
["cipher-1", "cipher-2", "cipher-3"],
|
||||
{
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
},
|
||||
);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
// Should produce 1 row (not 3) with totalItems = 3
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "user1@test.com",
|
||||
name: "User One",
|
||||
collection: "Test Collection",
|
||||
totalItems: "3", // Grouped count
|
||||
});
|
||||
});
|
||||
|
||||
it("should create separate rows for different access paths (group vs direct)", async () => {
|
||||
const userId1 = "user-1";
|
||||
const groupId1 = "group-1";
|
||||
const collectionId1 = "collection-1";
|
||||
const cipherId1 = "cipher-1";
|
||||
const cipherId2 = "cipher-2";
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId1,
|
||||
"Mixed Access Collection",
|
||||
[{ id: userId1, readOnly: false, hidePasswords: false, manage: false }],
|
||||
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [groupId1], // User has both direct AND group access
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock groups
|
||||
mockGroupApiService.getAll.mockResolvedValue([
|
||||
createMockGroup(groupId1, "Mixed Access Group"),
|
||||
]);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(cipherId1, [collectionId1]),
|
||||
createMockCipher(cipherId2, [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
// Should produce 2 rows: one for direct access, one for group access
|
||||
// Each with 2 ciphers
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((item) => item.totalItems === "2")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle edge cases with empty/missing data", async () => {
|
||||
const userId1 = "user-1";
|
||||
const collectionId1 = "collection-1";
|
||||
const cipherId1 = "cipher-1";
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId1,
|
||||
"", // Empty collection name
|
||||
[{ id: userId1, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(
|
||||
userId1,
|
||||
"user1@test.com",
|
||||
"", // Empty name
|
||||
{
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: false,
|
||||
groups: [],
|
||||
},
|
||||
),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(cipherId1, [collectionId1]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "user1@test.com", // Falls back to email
|
||||
collection: "memberAccessReportNoCollection", // Falls back to translation key
|
||||
group: "memberAccessReportNoGroup", // Falls back to translation key
|
||||
});
|
||||
});
|
||||
|
||||
it("should populate group names from GroupApiService", async () => {
|
||||
const userId1 = "user-1";
|
||||
const groupId1 = "group-1";
|
||||
const groupId2 = "group-2";
|
||||
const collectionId1 = "collection-1";
|
||||
const collectionId2 = "collection-2";
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(
|
||||
collectionId1,
|
||||
"Engineering Collection",
|
||||
[],
|
||||
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
createMockCollection(
|
||||
collectionId2,
|
||||
"Marketing Collection",
|
||||
[],
|
||||
[{ id: groupId2, readOnly: false, hidePasswords: false, manage: false }],
|
||||
),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: true,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: true,
|
||||
groups: [groupId1, groupId2],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock groups with actual names
|
||||
mockGroupApiService.getAll.mockResolvedValue([
|
||||
createMockGroup(groupId1, "Engineering Team"),
|
||||
createMockGroup(groupId2, "Marketing Team"),
|
||||
]);
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher("cipher-1", [collectionId1]),
|
||||
createMockCipher("cipher-2", [collectionId2]),
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
// Should have 2 rows, one per group
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Verify group names are populated correctly
|
||||
const groups = result.map((item) => item.group).sort();
|
||||
expect(groups).toEqual(["Engineering Team", "Marketing Team"]);
|
||||
|
||||
// Verify collections match groups
|
||||
expect(result.find((item) => item.group === "Engineering Team")?.collection).toBe(
|
||||
"Engineering Collection",
|
||||
);
|
||||
expect(result.find((item) => item.group === "Marketing Team")?.collection).toBe(
|
||||
"Marketing Collection",
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip ciphers with zero GUID", async () => {
|
||||
const userId1 = "user-1";
|
||||
const collectionId1 = "collection-1";
|
||||
const validCipherId = "cipher-1";
|
||||
const zeroGuid = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
createMockCollection(collectionId1, "Test Collection", [
|
||||
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
|
||||
]),
|
||||
] as CollectionAdminView[]),
|
||||
);
|
||||
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: false,
|
||||
resetPasswordEnrolled: false,
|
||||
groups: [],
|
||||
}),
|
||||
],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>);
|
||||
|
||||
// Mock ciphers including one with zero GUID
|
||||
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
|
||||
createMockCipher(validCipherId, [collectionId1]),
|
||||
createMockCipher(zeroGuid, [collectionId1]), // Should be filtered out
|
||||
] as CipherView[]);
|
||||
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].totalItems).toBe("1"); // Only counts valid cipher
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
// 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 { firstValueFrom, map, take } from "rxjs";
|
||||
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
CollectionAdminView,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Guid, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { GroupApiService } from "@bitwarden/web-vault/app/admin-console/organizations/core";
|
||||
import {
|
||||
getPermissionList,
|
||||
convertToPermission,
|
||||
@@ -22,6 +33,38 @@ import { MemberAccessReportView } from "../view/member-access-report.view";
|
||||
|
||||
import { MemberAccessReportApiService } from "./member-access-report-api.service";
|
||||
|
||||
/**
|
||||
* V2 data structures for frontend member-to-cipher mapping
|
||||
*/
|
||||
interface MemberAccessDataV2 {
|
||||
collectionMap: Map<string, CollectionAdminView>;
|
||||
organizationUserDataMap: Map<string, OrganizationUserData>;
|
||||
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
|
||||
}
|
||||
|
||||
interface OrganizationUserData {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
twoFactorEnabled: boolean;
|
||||
usesKeyConnector: boolean;
|
||||
resetPasswordEnrolled: boolean;
|
||||
}
|
||||
|
||||
interface MemberCipherAccess {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
accessType: "direct" | "group";
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class MemberAccessReportService {
|
||||
constructor(
|
||||
@@ -30,10 +73,19 @@ export class MemberAccessReportService {
|
||||
private encryptService: EncryptService,
|
||||
private keyService: KeyService,
|
||||
private accountService: AccountService,
|
||||
// V2 dependencies for frontend member-to-cipher mapping
|
||||
private collectionAdminService: CollectionAdminService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private groupApiService: GroupApiService,
|
||||
) {}
|
||||
/**
|
||||
* Transforms user data into a MemberAccessReportView.
|
||||
*
|
||||
* @deprecated Times out for large orgs
|
||||
* Use generateMemberAccessReportViewV2 instead. Will be removed after V2 rollout is complete.
|
||||
*
|
||||
* @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.
|
||||
@@ -71,6 +123,7 @@ export class MemberAccessReportService {
|
||||
userGuid: userGuid,
|
||||
name: userDataArray[0].userName,
|
||||
email: userDataArray[0].email,
|
||||
avatarColor: "", // V1 API doesn't provide avatarColor
|
||||
collectionsCount: collectionCount,
|
||||
groupsCount: groupCount,
|
||||
itemsCount: itemsCount,
|
||||
@@ -83,6 +136,10 @@ export class MemberAccessReportService {
|
||||
return memberAccessReportViewCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated V1 implementation - causes timeout for large orgs (5K+ members).
|
||||
* Use generateUserReportExportItemsV2 instead. Will be removed after V2 rollout is complete.
|
||||
*/
|
||||
async generateUserReportExportItems(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<MemberAccessExportItem[]> {
|
||||
@@ -124,7 +181,7 @@ export class MemberAccessReportService {
|
||||
? collectionName
|
||||
: this.i18nService.t("memberAccessReportNoCollection"),
|
||||
collectionPermission: report.collectionId
|
||||
? this.getPermissionText(report)
|
||||
? this.getPermissionTextFromAccess(report)
|
||||
: this.i18nService.t("memberAccessReportNoCollectionPermission"),
|
||||
totalItems: report.cipherIds
|
||||
.filter((_) => _ != "00000000-0000-0000-0000-000000000000")
|
||||
@@ -134,21 +191,410 @@ export class MemberAccessReportService {
|
||||
return exportItems.flat();
|
||||
}
|
||||
|
||||
private getPermissionText(accessDetails: MemberAccessResponse): string {
|
||||
/**
|
||||
* Shared logic for getting permission text from access details
|
||||
* @deprecated Use getPermissionTextCached with pre-built lookup map for better performance
|
||||
* @private
|
||||
*/
|
||||
private getPermissionTextFromAccess(access: {
|
||||
groupId?: string;
|
||||
collectionId: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
}): string {
|
||||
const permissionList = getPermissionList();
|
||||
const collectionSelectionView = new CollectionAccessSelectionView({
|
||||
id: accessDetails.groupId ?? accessDetails.collectionId,
|
||||
readOnly: accessDetails.readOnly,
|
||||
hidePasswords: accessDetails.hidePasswords,
|
||||
manage: accessDetails.manage,
|
||||
id: access.groupId ?? access.collectionId,
|
||||
readOnly: access.readOnly,
|
||||
hidePasswords: access.hidePasswords,
|
||||
manage: access.manage,
|
||||
});
|
||||
return this.i18nService.t(
|
||||
permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission text using cached lookup map (performance optimized)
|
||||
* @param access - Access details
|
||||
* @param permissionLookup - Pre-built map of permission to label ID
|
||||
* @private
|
||||
*/
|
||||
private getPermissionTextCached(
|
||||
access: {
|
||||
groupId?: string;
|
||||
collectionId: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
},
|
||||
permissionLookup: Map<string, string>,
|
||||
): string {
|
||||
const collectionSelectionView = new CollectionAccessSelectionView({
|
||||
id: access.groupId ?? access.collectionId,
|
||||
readOnly: access.readOnly,
|
||||
hidePasswords: access.hidePasswords,
|
||||
manage: access.manage,
|
||||
});
|
||||
const perm = convertToPermission(collectionSelectionView);
|
||||
const labelId = permissionLookup.get(perm);
|
||||
return this.i18nService.t(labelId ?? "");
|
||||
}
|
||||
|
||||
private getDistinctCount<T>(items: T[]): number {
|
||||
const uniqueItems = new Set(items);
|
||||
return uniqueItems.size;
|
||||
}
|
||||
|
||||
// ==================== V2 METHODS - Frontend Member Mapping ====================
|
||||
// These methods implement the Access Intelligence V2 pattern to avoid backend timeout issues.
|
||||
// V2 performs member-to-cipher mapping on the frontend using collection relationships,
|
||||
// eliminating the need for the problematic backend member-access endpoint for large orgs.
|
||||
|
||||
/**
|
||||
* Loads organization data (collections, users, groups) for V2 member mapping
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID
|
||||
* @returns Promise containing collection map, user metadata map, and group member map
|
||||
*/
|
||||
private async _loadOrganizationDataV2(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Promise<MemberAccessDataV2> {
|
||||
this.logService.debug("[MemberAccessReportService V2] Loading organization data");
|
||||
|
||||
// Fetch collections, users, and groups in parallel
|
||||
const [collections, orgUsersResponse, groups] = await Promise.all([
|
||||
firstValueFrom(
|
||||
this.collectionAdminService
|
||||
.collectionAdminViews$(organizationId, currentUserId)
|
||||
.pipe(take(1)),
|
||||
),
|
||||
this.organizationUserApiService.getAllUsers(organizationId, { includeGroups: true }),
|
||||
this.groupApiService.getAll(organizationId),
|
||||
]);
|
||||
|
||||
// Build collection map
|
||||
const collectionMap = new Map<string, CollectionAdminView>();
|
||||
collections.forEach((c) => collectionMap.set(c.id, c));
|
||||
|
||||
// Build group name lookup map
|
||||
const groupNameMap = new Map<string, string>();
|
||||
groups.forEach((g) => groupNameMap.set(g.id, g.name));
|
||||
|
||||
// Build user metadata and group member maps
|
||||
const organizationUserDataMap = new Map<string, OrganizationUserData>();
|
||||
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
|
||||
|
||||
for (const orgUser of orgUsersResponse.data) {
|
||||
// Build user metadata map
|
||||
if (orgUser.id) {
|
||||
organizationUserDataMap.set(orgUser.id, {
|
||||
userId: orgUser.id,
|
||||
name: orgUser.name || orgUser.email,
|
||||
email: orgUser.email,
|
||||
avatarColor: orgUser.avatarColor,
|
||||
twoFactorEnabled: orgUser.twoFactorEnabled || false,
|
||||
usesKeyConnector: orgUser.usesKeyConnector || false,
|
||||
resetPasswordEnrolled: orgUser.resetPasswordEnrolled || false,
|
||||
});
|
||||
}
|
||||
|
||||
// Build group member map
|
||||
if (orgUser.groups && orgUser.groups.length > 0) {
|
||||
for (const groupId of orgUser.groups) {
|
||||
let groupData = groupMemberMap.get(groupId);
|
||||
if (!groupData) {
|
||||
groupData = {
|
||||
groupName: groupNameMap.get(groupId) || "",
|
||||
memberIds: [],
|
||||
};
|
||||
groupMemberMap.set(groupId, groupData);
|
||||
}
|
||||
groupData.memberIds.push(orgUser.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.debug(
|
||||
`[MemberAccessReportService V2] Loaded ${collections.length} collections, ${organizationUserDataMap.size} users, ${groupMemberMap.size} groups`,
|
||||
);
|
||||
|
||||
return { collectionMap, organizationUserDataMap, groupMemberMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps ciphers to members using frontend collection mapping (V2)
|
||||
*
|
||||
* Groups by (user, collection, group) access path and tracks cipher IDs in Sets
|
||||
* to avoid creating redundant objects for large organizations.
|
||||
*
|
||||
* @param ciphers - Array of cipher views
|
||||
* @param orgData - Organization data containing collections, users, and groups
|
||||
* @returns Map of access paths with cipher ID sets
|
||||
*/
|
||||
private _mapCiphersToMembersV2(
|
||||
ciphers: CipherView[],
|
||||
orgData: MemberAccessDataV2,
|
||||
): Map<string, { access: MemberCipherAccess; cipherIds: Set<string> }> {
|
||||
const accessMap = new Map<string, { access: MemberCipherAccess; cipherIds: Set<string> }>();
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
// Skip ciphers without collections or with placeholder/invalid IDs (matches V1 behavior)
|
||||
if (
|
||||
!cipher.collectionIds ||
|
||||
cipher.collectionIds.length === 0 ||
|
||||
!cipher.id ||
|
||||
cipher.id === "00000000-0000-0000-0000-000000000000"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const collectionId of cipher.collectionIds) {
|
||||
const collection = orgData.collectionMap.get(collectionId);
|
||||
if (!collection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process direct user access
|
||||
for (const userAccess of collection.users) {
|
||||
const key = `${userAccess.id}|${collection.id}|direct`;
|
||||
let entry = accessMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
// First cipher for this access path - create new entry
|
||||
entry = {
|
||||
access: {
|
||||
userId: userAccess.id,
|
||||
cipherId: cipher.id, // Representative cipher (for backward compatibility)
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name,
|
||||
accessType: "direct",
|
||||
readOnly: userAccess.readOnly,
|
||||
hidePasswords: userAccess.hidePasswords,
|
||||
manage: userAccess.manage,
|
||||
},
|
||||
cipherIds: new Set([cipher.id]),
|
||||
};
|
||||
accessMap.set(key, entry);
|
||||
} else {
|
||||
// Add cipher to existing access path
|
||||
entry.cipherIds.add(cipher.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process group access
|
||||
for (const groupAccess of collection.groups) {
|
||||
const groupData = orgData.groupMemberMap.get(groupAccess.id);
|
||||
if (!groupData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const userId of groupData.memberIds) {
|
||||
const key = `${userId}|${collection.id}|${groupAccess.id}`;
|
||||
let entry = accessMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
// First cipher for this access path - create new entry
|
||||
entry = {
|
||||
access: {
|
||||
userId,
|
||||
cipherId: cipher.id, // Representative cipher (for backward compatibility)
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name,
|
||||
groupId: groupAccess.id,
|
||||
groupName: groupData.groupName,
|
||||
accessType: "group",
|
||||
readOnly: groupAccess.readOnly,
|
||||
hidePasswords: groupAccess.hidePasswords,
|
||||
manage: groupAccess.manage,
|
||||
},
|
||||
cipherIds: new Set([cipher.id]),
|
||||
};
|
||||
accessMap.set(key, entry);
|
||||
} else {
|
||||
// Add cipher to existing access path
|
||||
entry.cipherIds.add(cipher.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.debug(
|
||||
`[MemberAccessReportService V2] Mapped ${ciphers.length} ciphers to ${accessMap.size} access paths`,
|
||||
);
|
||||
|
||||
return accessMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ciphers with 5-minute timeout protection
|
||||
* @private
|
||||
*/
|
||||
private async _fetchCiphersWithTimeout(organizationId: OrganizationId): Promise<CipherView[]> {
|
||||
const TIMEOUT_MS = 300000; // 5 minutes
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
"Cipher fetch timed out after 5 minutes. Organization may be too large for this report. Please contact support.",
|
||||
),
|
||||
);
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const fetchPromise = this.cipherService.getAllFromApiForOrganization(organizationId);
|
||||
|
||||
return Promise.race([fetchPromise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate member access report using V2 frontend mapping
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @returns Promise of MemberAccessReportView array
|
||||
*/
|
||||
async generateMemberAccessReportViewV2(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<MemberAccessReportView[]> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
this.logService.debug("[MemberAccessReportService V2] Starting report generation");
|
||||
|
||||
// Load organization data
|
||||
const orgData = await this._loadOrganizationDataV2(organizationId, userId);
|
||||
|
||||
// Log organization complexity
|
||||
this.logService.info(
|
||||
`[MemberAccessReport V2] Organization size: ${orgData.organizationUserDataMap.size} users, ${orgData.collectionMap.size} collections`,
|
||||
);
|
||||
|
||||
// Get all org ciphers with timeout protection
|
||||
const ciphers = await this._fetchCiphersWithTimeout(organizationId);
|
||||
|
||||
this.logService.info(`[MemberAccessReport V2] Fetched ${ciphers.length} ciphers`);
|
||||
|
||||
// Map ciphers to members
|
||||
const accessMap = this._mapCiphersToMembersV2(ciphers, orgData);
|
||||
|
||||
// Aggregate by user
|
||||
const userAccessMap = new Map<
|
||||
string,
|
||||
{
|
||||
collections: Set<string>;
|
||||
groups: Set<string>;
|
||||
items: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const { access, cipherIds } of accessMap.values()) {
|
||||
let userData = userAccessMap.get(access.userId);
|
||||
if (!userData) {
|
||||
userData = {
|
||||
collections: new Set(),
|
||||
groups: new Set(),
|
||||
items: new Set(),
|
||||
};
|
||||
userAccessMap.set(access.userId, userData);
|
||||
}
|
||||
|
||||
userData.collections.add(access.collectionId);
|
||||
if (access.groupId) {
|
||||
userData.groups.add(access.groupId);
|
||||
}
|
||||
// Add all ciphers from this access path
|
||||
for (const cipherId of cipherIds) {
|
||||
userData.items.add(cipherId);
|
||||
}
|
||||
}
|
||||
|
||||
// Build report views
|
||||
const reportViews: MemberAccessReportView[] = [];
|
||||
for (const [userId, data] of userAccessMap.entries()) {
|
||||
const metadata = orgData.organizationUserDataMap.get(userId);
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reportViews.push({
|
||||
userGuid: userId as Guid,
|
||||
name: metadata.name,
|
||||
email: metadata.email,
|
||||
avatarColor: metadata.avatarColor,
|
||||
collectionsCount: data.collections.size,
|
||||
groupsCount: data.groups.size,
|
||||
itemsCount: data.items.size,
|
||||
usesKeyConnector: metadata.usesKeyConnector,
|
||||
});
|
||||
}
|
||||
|
||||
this.logService.debug(
|
||||
`[MemberAccessReportService V2] Generated report for ${reportViews.length} users`,
|
||||
);
|
||||
|
||||
return reportViews;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate export items using V2 frontend mapping
|
||||
*
|
||||
* @param organizationId The organization ID
|
||||
* @returns Promise of MemberAccessExportItem array
|
||||
*/
|
||||
async generateUserReportExportItemsV2(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<MemberAccessExportItem[]> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const orgData = await this._loadOrganizationDataV2(organizationId, userId);
|
||||
const ciphers = await this._fetchCiphersWithTimeout(organizationId);
|
||||
const accessMap = this._mapCiphersToMembersV2(ciphers, orgData);
|
||||
|
||||
// Pre-fetch i18n strings to avoid repeated lookups
|
||||
const twoFactorEnabledTrue = this.i18nService.t("memberAccessReportTwoFactorEnabledTrue");
|
||||
const twoFactorEnabledFalse = this.i18nService.t("memberAccessReportTwoFactorEnabledFalse");
|
||||
const accountRecoveryEnabledTrue = this.i18nService.t(
|
||||
"memberAccessReportAuthenticationEnabledTrue",
|
||||
);
|
||||
const accountRecoveryEnabledFalse = this.i18nService.t(
|
||||
"memberAccessReportAuthenticationEnabledFalse",
|
||||
);
|
||||
const noGroup = this.i18nService.t("memberAccessReportNoGroup");
|
||||
const noCollection = this.i18nService.t("memberAccessReportNoCollection");
|
||||
const noCollectionPermission = this.i18nService.t("memberAccessReportNoCollectionPermission");
|
||||
|
||||
// Build permission lookup map once instead of calling getPermissionList() for each item
|
||||
const permissionList = getPermissionList();
|
||||
const permissionLookup = new Map<string, string>();
|
||||
permissionList.forEach((p) => {
|
||||
permissionLookup.set(p.perm, p.labelId);
|
||||
});
|
||||
|
||||
const exportItems: MemberAccessExportItem[] = [];
|
||||
for (const { access, cipherIds } of accessMap.values()) {
|
||||
const metadata = orgData.organizationUserDataMap.get(access.userId);
|
||||
|
||||
exportItems.push({
|
||||
email: metadata?.email ?? "",
|
||||
name: metadata?.name ?? "",
|
||||
twoStepLogin: metadata?.twoFactorEnabled ? twoFactorEnabledTrue : twoFactorEnabledFalse,
|
||||
accountRecovery: metadata?.resetPasswordEnrolled
|
||||
? accountRecoveryEnabledTrue
|
||||
: accountRecoveryEnabledFalse,
|
||||
group: access.groupName || noGroup,
|
||||
collection: access.collectionName || noCollection,
|
||||
collectionPermission: access.collectionId
|
||||
? this.getPermissionTextCached(access, permissionLookup)
|
||||
: noCollectionPermission,
|
||||
totalItems: cipherIds.size.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return exportItems;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Guid } from "@bitwarden/common/types/guid";
|
||||
export type MemberAccessReportView = {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
collectionsCount: number;
|
||||
groupsCount: number;
|
||||
itemsCount: number;
|
||||
|
||||
Reference in New Issue
Block a user