diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index ef8c109bc4b..7ea2abb5d08 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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"
},
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html
index 440e955a226..6769998e2c8 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html
@@ -9,7 +9,7 @@
>
{{ "export" | i18n }}
-
+
}
@@ -22,11 +22,11 @@
@if (isLoading) {
-
+
{{ row.name }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.stories.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.stories.ts
new file mode 100644
index 00000000000..5e00a0cf5d1
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.stories.ts
@@ -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 {
+ getApplicationVersion = () => Promise.resolve("2024.1.0");
+ getClientType = () => ClientType.Web;
+ isSelfHost = () => false;
+}
+
+export default {
+ title: "DIRT/Reports/Member Access Report",
+ component: MemberAccessReportComponent,
+ decorators: [
+ componentWrapperDecorator(
+ (story) =>
+ `${story}
`,
+ ),
+ 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;
+
+type Story = StoryObj;
+
+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([]),
+ },
+ },
+ ],
+ }),
+ ],
+};
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
index 445cee6683c..04e6c50dd51 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
@@ -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 => {
+ 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" },
});
};
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts
index 615e6d079b2..bab0d7f228e 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts
@@ -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();
const mockEncryptService = mock();
const userId = newGuid() as UserId;
const mockAccountService = mockAccountServiceWith(userId);
const mockKeyService = mock();
+ const mockCollectionAdminService = mock();
+ const mockOrganizationUserApiService = mock();
+ const mockCipherService = mock();
+ const mockLogService = mock();
+ const mockGroupApiService = mock();
let memberAccessReportService: MemberAccessReportService;
const i18nMock = mock({
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 =>
+ ({
+ id,
+ name,
+ users,
+ groups,
+ }) as Partial;
+
+ const createMockOrganizationUser = (
+ id: string,
+ email: string,
+ name: string | null | undefined,
+ options: {
+ twoFactorEnabled?: boolean;
+ usesKeyConnector?: boolean;
+ resetPasswordEnrolled?: boolean;
+ groups?: string[];
+ avatarColor?: string;
+ } = {},
+ ): Partial => ({
+ 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 => ({
+ 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);
+
+ 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);
+
+ // 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);
+
+ // 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);
+
+ // 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);
+
+ // 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);
+
+ // 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);
+
+ 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);
+
+ // 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);
+
+ 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);
+
+ // 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);
+
+ // 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
+ });
+ });
});
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts
index bb14b61006e..046ed070e93 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts
@@ -1,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;
+ organizationUserDataMap: Map;
+ groupMemberMap: Map;
+}
+
+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 {
@@ -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 {
+ 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(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 {
+ 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();
+ collections.forEach((c) => collectionMap.set(c.id, c));
+
+ // Build group name lookup map
+ const groupNameMap = new Map();
+ groups.forEach((g) => groupNameMap.set(g.id, g.name));
+
+ // Build user metadata and group member maps
+ const organizationUserDataMap = new Map();
+ const groupMemberMap = new Map();
+
+ 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 }> {
+ const accessMap = new Map }>();
+
+ 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 {
+ const TIMEOUT_MS = 300000; // 5 minutes
+ let timeoutId: NodeJS.Timeout;
+
+ const timeoutPromise = new Promise((_, 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 {
+ 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;
+ groups: Set;
+ items: Set;
+ }
+ >();
+
+ 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 {
+ 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();
+ 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;
+ }
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts
index 5412babc0e4..d004f3dc720 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/view/member-access-report.view.ts
@@ -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;