From ea76760782543b394434f95c4695960a14a1ae3f Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:11:05 +0100 Subject: [PATCH] [AC-2508][AC-2511] member access report view and export logic (#10011) * Added new report card and FeatureFlag for MemberAccessReport * Add new "isEnterpriseOrgGuard" * Add member access icon * Show upgrade organization dialog for enterprise on member access report click * verify member access featureflag on enterprise org guard * add comment with TODO information for follow up task * Initial member access report component * Improved readability, removed path to wrong component and refactored buildReports to use the productType * finished MemberAccessReport layout and added temporary service to provide mock data * Moved member-access-report files to bitwarden_license/ Removed unnecessary files * Added new tools path on bitwarden_license to the CODEOWNERS file * added member access description to the messages.json * layout changes to member access report * Created new reports-routing under bitwarden_license Moved member-access-report files to corresponding subfolder * Added search logic * Removed routing from member-access-report BL component on OSS. Added member-access-report navigation to organizations-routing on BL * removed unnecessary ng-container * Added OrganizationPermissionsGuard and canAccessReports validation to member-access-report navigation * replaced deprecated search code with searchControl * Address PR feedback * removed unnecessary canAccessReports method * Added report-utils class with generic functions to support report operations * Added member access report mock * Added member access report specific logic * Splitted code into different classes that explained their objective. * fixed member access report service test cases * Addressed PR feedback * Creating a service to return the data for the member access report * added missing ExportHelper on index.ts * Corrected property names on member access report component view * removed duplicated service --- .../app/tools/reports/report-utils.spec.ts | 187 ++++++++++++++++++ .../web/src/app/tools/reports/report-utils.ts | 71 +++++++ .../member-access-report.component.html | 14 +- .../member-access-report.component.ts | 44 ++++- .../member-access-report.service.ts | 39 ---- .../model/member-access-report.model.ts | 23 +++ .../member-access-report-api.service.ts | 12 ++ .../member-access-report.abstraction.ts | 11 ++ .../services/member-access-report.mock.ts | 137 +++++++++++++ .../member-access-report.service.spec.ts | 86 ++++++++ .../services/member-access-report.service.ts | 113 +++++++++++ .../view/member-access-export.view.ts | 21 ++ .../view/member-access-report.view.ts | 10 +- .../vault-export-core/src/index.ts | 1 + 14 files changed, 713 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/app/tools/reports/report-utils.spec.ts create mode 100644 apps/web/src/app/tools/reports/report-utils.ts delete mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.service.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/model/member-access-report.model.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report-api.service.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.abstraction.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.mock.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts create mode 100644 bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-export.view.ts diff --git a/apps/web/src/app/tools/reports/report-utils.spec.ts b/apps/web/src/app/tools/reports/report-utils.spec.ts new file mode 100644 index 0000000000..6312423ed8 --- /dev/null +++ b/apps/web/src/app/tools/reports/report-utils.spec.ts @@ -0,0 +1,187 @@ +import * as papa from "papaparse"; + +jest.mock("papaparse", () => ({ + unparse: jest.fn(), +})); +import { collectProperty, exportToCSV, getUniqueItems, sumValue } from "./report-utils"; + +describe("getUniqueItems", () => { + it("should return unique items based on a specified key", () => { + const items = [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + { id: 1, name: "Item 1 Duplicate" }, + ]; + + const uniqueItems = getUniqueItems(items, (item) => item.id); + + expect(uniqueItems).toEqual([ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ]); + }); + + it("should return an empty array when input is empty", () => { + const items: { id: number; name: string }[] = []; + + const uniqueItems = getUniqueItems(items, (item) => item.id); + + expect(uniqueItems).toEqual([]); + }); +}); + +describe("sumValue", () => { + it("should return the sum of all values of a specified property", () => { + const items = [{ value: 10 }, { value: 20 }, { value: 30 }]; + + const sum = sumValue(items, (item) => item.value); + + expect(sum).toBe(60); + }); + + it("should return 0 when input is empty", () => { + const items: { value: number }[] = []; + + const sum = sumValue(items, (item) => item.value); + + expect(sum).toBe(0); + }); + + it("should handle negative numbers", () => { + const items = [{ value: -10 }, { value: 20 }, { value: -5 }]; + + const sum = sumValue(items, (item) => item.value); + + expect(sum).toBe(5); + }); +}); + +describe("collectProperty", () => { + it("should collect a specified property from an array of objects", () => { + const items = [{ values: [1, 2, 3] }, { values: [4, 5, 6] }]; + + const aggregated = collectProperty(items, "values"); + + expect(aggregated).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("should return an empty array when input is empty", () => { + const items: { values: number[] }[] = []; + + const aggregated = collectProperty(items, "values"); + + expect(aggregated).toEqual([]); + }); + + it("should handle objects with empty arrays as properties", () => { + const items = [{ values: [] }, { values: [4, 5, 6] }]; + + const aggregated = collectProperty(items, "values"); + + expect(aggregated).toEqual([4, 5, 6]); + }); +}); + +describe("exportToCSV", () => { + const data = [ + { + email: "john@example.com", + name: "John Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + { + email: "jane@example.com", + name: "Jane Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + ]; + test("exportToCSV should correctly export data to CSV format", () => { + const mockExportData = [ + { id: "1", name: "Alice", email: "alice@example.com" }, + { id: "2", name: "Bob", email: "bob@example.com" }, + ]; + const mockedCsvOutput = "mocked CSV output"; + (papa.unparse as jest.Mock).mockReturnValue(mockedCsvOutput); + + exportToCSV(mockExportData); + + const csvOutput = papa.unparse(mockExportData); + expect(csvOutput).toMatch(mockedCsvOutput); + }); + + it("should map data according to the headers and export to CSV", () => { + const headers = { + email: "Email Address", + name: "Full Name", + twoFactorEnabled: "Two-Step Login", + accountRecoveryEnabled: "Account Recovery", + }; + + exportToCSV(data, headers); + + const expectedMappedData = [ + { + "Email Address": "john@example.com", + "Full Name": "John Doe", + "Two-Step Login": "On", + "Account Recovery": "Off", + }, + { + "Email Address": "jane@example.com", + "Full Name": "Jane Doe", + "Two-Step Login": "On", + "Account Recovery": "Off", + }, + ]; + + expect(papa.unparse).toHaveBeenCalledWith(expectedMappedData); + }); + + it("should use original keys if headers are not provided", () => { + exportToCSV(data); + + const expectedMappedData = [ + { + email: "john@example.com", + name: "John Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + { + email: "jane@example.com", + name: "Jane Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + ]; + + expect(papa.unparse).toHaveBeenCalledWith(expectedMappedData); + }); + + it("should mix original keys if headers are not fully provided", () => { + const headers = { + email: "Email Address", + }; + + exportToCSV(data, headers); + + const expectedMappedData = [ + { + "Email Address": "john@example.com", + name: "John Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + { + "Email Address": "jane@example.com", + name: "Jane Doe", + twoFactorEnabled: "On", + accountRecoveryEnabled: "Off", + }, + ]; + + expect(papa.unparse).toHaveBeenCalledWith(expectedMappedData); + }); +}); diff --git a/apps/web/src/app/tools/reports/report-utils.ts b/apps/web/src/app/tools/reports/report-utils.ts new file mode 100644 index 0000000000..58211e7ae5 --- /dev/null +++ b/apps/web/src/app/tools/reports/report-utils.ts @@ -0,0 +1,71 @@ +import * as papa from "papaparse"; + +/** + * Returns an array of unique items from a collection based on a specified key. + * + * @param {T[]} items The array of items to process. + * @param {(item: T) => K} keySelector A function that selects the key to identify uniqueness. + * @returns {T[]} An array of unique items. + */ +export function getUniqueItems(items: T[], keySelector: (item: T) => K): T[] { + const uniqueKeys = new Set(); + const uniqueItems: T[] = []; + + items.forEach((item) => { + const key = keySelector(item); + if (!uniqueKeys.has(key)) { + uniqueKeys.add(key); + uniqueItems.push(item); + } + }); + + return uniqueItems; +} +/** + * Sums all the values of a specified numeric property in an array of objects. + * + * @param {T[]} array - The array of objects containing the property to be summed. + * @param {(item: T) => number} getProperty - A function that returns the numeric property value for each object. + * @returns {number} - The total sum of the specified property values. + */ +export function sumValue(values: T[], getProperty: (item: T) => number): number { + return values.reduce((sum, item) => sum + getProperty(item), 0); +} + +/** + * Collects a specified property from an array of objects. + * + * @param array The array of objects to collect from. + * @param property The property to collect. + * @returns An array of aggregated values from the specified property. + */ +export function collectProperty(array: T[], property: K): V[] { + const collected: V[] = array + .map((i) => i[property]) + .filter((value) => Array.isArray(value)) + .flat() as V[]; + + return collected; +} + +/** + * Exports an array of objects to a CSV string. + * + * @param {T[]} data - An array of objects to be exported. + * @param {[key in keyof T]: string } headers - A mapping of keys of type T to their corresponding header names. + * @returns A string in csv format from the input data. + */ +export function exportToCSV(data: T[], headers?: Partial<{ [key in keyof T]: string }>): string { + const mappedData = data.map((item) => { + const mappedItem: { [key: string]: string } = {}; + for (const key in item) { + if (headers != null && headers[key as keyof T]) { + mappedItem[headers[key as keyof T]] = String(item[key as keyof T]); + } else { + mappedItem[key] = String(item[key as keyof T]); + } + } + return mappedItem; + }); + return papa.unparse(mappedData); +} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html index 9950bce6ef..cd7d41da22 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html @@ -5,7 +5,7 @@ class="tw-grow" > - @@ -21,9 +21,9 @@ Members - Groups - Collections - Items + Groups + Collections + Items @@ -42,9 +42,9 @@ - {{ r.groups }} - {{ r.collections }} - {{ r.items }} + {{ r.groupsCount }} + {{ r.collectionsCount }} + {{ r.itemsCount }} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts index 5e5a506b2f..a169d44701 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts @@ -1,33 +1,67 @@ import { Component, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; -import { debounceTime } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { debounceTime, firstValueFrom } from "rxjs"; +import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { SearchModule, TableDataSource } from "@bitwarden/components"; +import { ExportHelper } from "@bitwarden/vault-export-core"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { exportToCSV } from "@bitwarden/web-vault/app/tools/reports/report-utils"; -import { MemberAccessReportService } from "./member-access-report.service"; +import { MemberAccessReportApiService } from "./services/member-access-report-api.service"; +import { MemberAccessReportServiceAbstraction } from "./services/member-access-report.abstraction"; +import { MemberAccessReportService } from "./services/member-access-report.service"; +import { userReportItemHeaders } from "./view/member-access-export.view"; import { MemberAccessReportView } from "./view/member-access-report.view"; @Component({ selector: "member-access-report", templateUrl: "member-access-report.component.html", imports: [SharedModule, SearchModule, HeaderModule], + providers: [ + safeProvider({ + provide: MemberAccessReportServiceAbstraction, + useClass: MemberAccessReportService, + deps: [MemberAccessReportApiService], + }), + ], standalone: true, }) export class MemberAccessReportComponent implements OnInit { protected dataSource = new TableDataSource(); protected searchControl = new FormControl("", { nonNullable: true }); + protected organizationId: OrganizationId; - constructor(protected reportService: MemberAccessReportService) { + constructor( + private route: ActivatedRoute, + protected reportService: MemberAccessReportService, + protected fileDownloadService: FileDownloadService, + ) { // Connect the search input to the table dataSource filter input this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); } - ngOnInit() { - this.dataSource.data = this.reportService.getMemberAccessMockData(); + async ngOnInit() { + const params = await firstValueFrom(this.route.params); + this.organizationId = params.organizationId; + this.dataSource.data = this.reportService.generateMemberAccessReportView(); } + + exportReportAction = async (): Promise => { + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("member-access"), + blobData: exportToCSV( + await this.reportService.generateUserReportExportItems(this.organizationId), + userReportItemHeaders, + ), + blobOptions: { type: "text/plain" }, + }); + }; } diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.service.ts deleted file mode 100644 index d77e93b3d0..0000000000 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { MemberAccessReportView } from "./view/member-access-report.view"; - -@Injectable({ providedIn: "root" }) -export class MemberAccessReportService { - //Temporary method to provide mock data for test purposes only - getMemberAccessMockData(): MemberAccessReportView[] { - const memberAccess = new MemberAccessReportView(); - memberAccess.email = "sjohnson@email.com"; - memberAccess.name = "Sarah Johnson"; - memberAccess.groups = 3; - memberAccess.collections = 12; - memberAccess.items = 3; - - const memberAccess2 = new MemberAccessReportView(); - memberAccess2.email = "jlull@email.com"; - memberAccess2.name = "James Lull"; - memberAccess2.groups = 2; - memberAccess2.collections = 24; - memberAccess2.items = 2; - - const memberAccess3 = new MemberAccessReportView(); - memberAccess3.email = "bwilliams@email.com"; - memberAccess3.name = "Beth Williams"; - memberAccess3.groups = 6; - memberAccess3.collections = 12; - memberAccess3.items = 1; - - const memberAccess4 = new MemberAccessReportView(); - memberAccess4.email = "rwilliams@email.com"; - memberAccess4.name = "Ray Williams"; - memberAccess4.groups = 5; - memberAccess4.collections = 21; - memberAccess4.items = 2; - - return [memberAccess, memberAccess2, memberAccess3, memberAccess4]; - } -} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/model/member-access-report.model.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/model/member-access-report.model.ts new file mode 100644 index 0000000000..35f37eb45f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/model/member-access-report.model.ts @@ -0,0 +1,23 @@ +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +export type MemberAccessCollectionModel = { + id: string; + name: EncString; + itemCount: number; +}; + +export type MemberAccessGroupModel = { + id: string; + name: string; + itemCount: number; + collections: MemberAccessCollectionModel[]; +}; + +export type MemberAccessReportModel = { + userName: string; + email: string; + twoFactorEnabled: boolean; + accountRecoveryEnabled: boolean; + collections: MemberAccessCollectionModel[]; + groups: MemberAccessGroupModel[]; +}; diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report-api.service.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report-api.service.ts new file mode 100644 index 0000000000..ad25308b61 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report-api.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@angular/core"; + +import { MemberAccessReportModel } from "../model/member-access-report.model"; + +import { memberAccessReportsMock } from "./member-access-report.mock"; + +@Injectable({ providedIn: "root" }) +export class MemberAccessReportApiService { + getMemberAccessData(): MemberAccessReportModel[] { + return memberAccessReportsMock; + } +} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.abstraction.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.abstraction.ts new file mode 100644 index 0000000000..f26961e11d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.abstraction.ts @@ -0,0 +1,11 @@ +import { OrganizationId } from "@bitwarden/common/src/types/guid"; + +import { MemberAccessExportItem } from "../view/member-access-export.view"; +import { MemberAccessReportView } from "../view/member-access-report.view"; + +export abstract class MemberAccessReportServiceAbstraction { + generateMemberAccessReportView: () => MemberAccessReportView[]; + generateUserReportExportItems: ( + organizationId: OrganizationId, + ) => Promise; +} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.mock.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.mock.ts new file mode 100644 index 0000000000..4a0ad310c3 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.mock.ts @@ -0,0 +1,137 @@ +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import { MemberAccessReportModel } from "../model/member-access-report.model"; + +export const memberAccessReportsMock: MemberAccessReportModel[] = [ + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + collections: [ + { + id: "c1", + name: new EncString( + "2.UiXa3L3Ol1G4QnfFfBjMQw==|sbVTj0EiEkhIrDiropn2Cg==|82P78YgmapW4TdN9jQJgMWKv2gGyK1AnGkr+W9/sq+A=", + ), + itemCount: 10, + }, + { id: "c2", name: new EncString("Collection 2"), itemCount: 20 }, + { id: "c3", name: new EncString("Collection 3"), itemCount: 30 }, + ], + groups: [ + { + id: "g1", + name: "Group 1", + itemCount: 3, + collections: [ + { + id: "c6", + name: new EncString( + "2.UiXa3L3Ol1G4QnfFfBjMQw==|sbVTj0EiEkhIrDiropn2Cg==|82P78YgmapW4TdN9jQJgMWKv2gGyK1AnGkr+W9/sq+A=", + ), + itemCount: 10, + }, + { id: "c2", name: new EncString("Collection 2"), itemCount: 20 }, + ], + }, + { + id: "g2", + name: "Group 2", + itemCount: 2, + collections: [ + { id: "c2", name: new EncString("Collection 2"), itemCount: 20 }, + { id: "c3", name: new EncString("Collection 3"), itemCount: 30 }, + ], + }, + { + id: "g3", + name: "Group 3", + itemCount: 2, + collections: [ + { + id: "c1", + name: new EncString( + "2.UiXa3L3Ol1G4QnfFfBjMQw==|sbVTj0EiEkhIrDiropn2Cg==|82P78YgmapW4TdN9jQJgMWKv2gGyK1AnGkr+W9/sq+A=", + ), + itemCount: 10, + }, + { id: "c3", name: new EncString("Collection 3"), itemCount: 30 }, + ], + }, + ], + }, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + collections: [ + { id: "c4", name: new EncString("Collection 4"), itemCount: 5 }, + { id: "c5", name: new EncString("Collection 5"), itemCount: 15 }, + ], + groups: [ + { + id: "g4", + name: "Group 4", + itemCount: 2, + collections: [ + { id: "c4", name: new EncString("Collection 4"), itemCount: 5 }, + { id: "c5", name: new EncString("Collection 5"), itemCount: 15 }, + ], + }, + { + id: "g5", + name: "Group 5", + itemCount: 1, + collections: [{ id: "c5", name: new EncString("Collection 5"), itemCount: 15 }], + }, + ], + }, + { + userName: "Beth Williams", + email: "bwilliams@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + collections: [{ id: "c6", name: new EncString("Collection 6"), itemCount: 25 }], + groups: [ + { + id: "g6", + name: "Group 6", + itemCount: 1, + collections: [{ id: "c4", name: new EncString("Collection 4"), itemCount: 35 }], + }, + ], + }, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + collections: [ + { id: "c7", name: new EncString("Collection 7"), itemCount: 8 }, + { id: "c8", name: new EncString("Collection 8"), itemCount: 12 }, + { id: "c9", name: new EncString("Collection 9"), itemCount: 16 }, + ], + groups: [ + { + id: "g9", + name: "Group 9", + itemCount: 1, + collections: [{ id: "c7", name: new EncString("Collection 7"), itemCount: 8 }], + }, + { + id: "g10", + name: "Group 10", + itemCount: 1, + collections: [{ id: "c8", name: new EncString("Collection 8"), itemCount: 12 }], + }, + { + id: "g11", + name: "Group 11", + itemCount: 1, + collections: [{ id: "c9", name: new EncString("Collection 9"), itemCount: 16 }], + }, + ], + }, +]; diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.spec.ts new file mode 100644 index 0000000000..6a4e53ce23 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.spec.ts @@ -0,0 +1,86 @@ +import { mock } from "jest-mock-extended"; + +import { OrganizationId } from "@bitwarden/common/src/types/guid"; + +import { MemberAccessReportApiService } from "./member-access-report-api.service"; +import { memberAccessReportsMock } from "./member-access-report.mock"; +import { MemberAccessReportService } from "./member-access-report.service"; +describe("ImportService", () => { + const mockOrganizationId = "mockOrgId" as OrganizationId; + const reportApiService = mock(); + let memberAccessReportService: MemberAccessReportService; + + beforeEach(() => { + reportApiService.getMemberAccessData.mockImplementation(() => memberAccessReportsMock); + memberAccessReportService = new MemberAccessReportService(reportApiService); + }); + + describe("generateMemberAccessReportView", () => { + it("should generate member access report view", () => { + const result = memberAccessReportService.generateMemberAccessReportView(); + + expect(result).toEqual([ + { + name: "Sarah Johnson", + email: "sjohnson@email.com", + collectionsCount: 4, + groupsCount: 3, + itemsCount: 70, + }, + { + name: "James Lull", + email: "jlull@email.com", + collectionsCount: 2, + groupsCount: 2, + itemsCount: 20, + }, + { + name: "Beth Williams", + email: "bwilliams@email.com", + collectionsCount: 2, + groupsCount: 1, + itemsCount: 60, + }, + { + name: "Ray Williams", + email: "rwilliams@email.com", + collectionsCount: 3, + groupsCount: 3, + itemsCount: 36, + }, + ]); + }); + }); + + describe("generateUserReportExportItems", () => { + it("should generate user report export items", async () => { + const result = + await memberAccessReportService.generateUserReportExportItems(mockOrganizationId); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: "sjohnson@email.com", + name: "Sarah Johnson", + twoStepLogin: "On", + accountRecovery: "On", + group: "Group 1", + collection: expect.any(String), + collectionPermission: "read only", + totalItems: "10", + }), + expect.objectContaining({ + email: "jlull@email.com", + name: "James Lull", + twoStepLogin: "Off", + accountRecovery: "Off", + group: "(No group)", + collection: expect.any(String), + collectionPermission: "read only", + totalItems: "15", + }), + ]), + ); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts new file mode 100644 index 0000000000..6f0cb92646 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from "@angular/core"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + collectProperty, + getUniqueItems, + sumValue, +} from "@bitwarden/web-vault/app/tools/reports/report-utils"; + +import { + MemberAccessCollectionModel, + MemberAccessGroupModel, +} from "../model/member-access-report.model"; +import { MemberAccessExportItem } from "../view/member-access-export.view"; +import { MemberAccessReportView } from "../view/member-access-report.view"; + +import { MemberAccessReportApiService } from "./member-access-report-api.service"; + +@Injectable({ providedIn: "root" }) +export class MemberAccessReportService { + constructor(private reportApiService: MemberAccessReportApiService) {} + /** + * Transforms user data into a MemberAccessReportView. + * + * @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. + */ + generateMemberAccessReportView(): MemberAccessReportView[] { + const memberAccessReportViewCollection: MemberAccessReportView[] = []; + const memberAccessData = this.reportApiService.getMemberAccessData(); + memberAccessData.forEach((userData) => { + const name = userData.userName; + const email = userData.email; + const groupCollections = collectProperty< + MemberAccessGroupModel, + "collections", + MemberAccessCollectionModel + >(userData.groups, "collections"); + + const uniqueCollections = getUniqueItems( + [...groupCollections, ...userData.collections], + (item: MemberAccessCollectionModel) => item.id, + ); + const collectionsCount = uniqueCollections.length; + const groupsCount = userData.groups.length; + const itemsCount = sumValue( + uniqueCollections, + (collection: MemberAccessCollectionModel) => collection.itemCount, + ); + + memberAccessReportViewCollection.push({ + name: name, + email: email, + collectionsCount: collectionsCount, + groupsCount: groupsCount, + itemsCount: itemsCount, + }); + }); + + return memberAccessReportViewCollection; + } + + async generateUserReportExportItems( + organizationId: OrganizationId, + ): Promise { + const memberAccessReports = this.reportApiService.getMemberAccessData(); + const userReportItemPromises = memberAccessReports.flatMap(async (memberAccessReport) => { + const partialMemberReportItem: Partial = { + email: memberAccessReport.email, + name: memberAccessReport.userName, + twoStepLogin: memberAccessReport.twoFactorEnabled ? "On" : "Off", + accountRecovery: memberAccessReport.accountRecoveryEnabled ? "On" : "Off", + }; + const groupCollectionPromises = memberAccessReport.groups.map(async (group) => { + const groupPartialReportItem = { ...partialMemberReportItem, group: group.name }; + return await this.buildReportItemFromCollection( + group.collections, + groupPartialReportItem, + organizationId, + ); + }); + const noGroupPartialReportItem = { ...partialMemberReportItem, group: "(No group)" }; + const noGroupCollectionPromises = await this.buildReportItemFromCollection( + memberAccessReport.collections, + noGroupPartialReportItem, + organizationId, + ); + + return Promise.all([...groupCollectionPromises, noGroupCollectionPromises]); + }); + + const nestedUserReportItems = (await Promise.all(userReportItemPromises)).flat(); + return nestedUserReportItems.flat(); + } + + async buildReportItemFromCollection( + memberAccessCollections: MemberAccessCollectionModel[], + partialReportItem: Partial, + organizationId: string, + ): Promise { + const reportItemPromises = memberAccessCollections.map(async (collection) => { + return { + ...partialReportItem, + collection: await collection.name.decrypt(organizationId), + collectionPermission: "read only", //TODO update this value + totalItems: collection.itemCount.toString(), + }; + }); + + return Promise.all(reportItemPromises); + } +} diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-export.view.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-export.view.ts new file mode 100644 index 0000000000..be9035e1f2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-export.view.ts @@ -0,0 +1,21 @@ +export type MemberAccessExportItem = { + email?: string; + name?: string; + twoStepLogin?: string; + accountRecovery?: string; + group?: string; + collection: string; + collectionPermission: string; + totalItems: string; +}; + +export const userReportItemHeaders: { [key in keyof MemberAccessExportItem]: string } = { + email: "Email Address", + name: "Full Name", + twoStepLogin: "Two-Step Login", + accountRecovery: "Account Recovery", + group: "Group Name", + collection: "Collection Name", + collectionPermission: "Collection Permission", + totalItems: "Total Items", +}; diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-report.view.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-report.view.ts index bc9947b3b3..eeb8cfee4f 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-report.view.ts +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/view/member-access-report.view.ts @@ -1,7 +1,7 @@ -export class MemberAccessReportView { +export type MemberAccessReportView = { name: string; email: string; - collections: number; - groups: number; - items: number; -} + collectionsCount: number; + groupsCount: number; + itemsCount: number; +}; diff --git a/libs/tools/export/vault-export/vault-export-core/src/index.ts b/libs/tools/export/vault-export/vault-export-core/src/index.ts index 8189bd9801..46166750ad 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/index.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/index.ts @@ -6,3 +6,4 @@ export * from "./services/org-vault-export.service.abstraction"; export * from "./services/org-vault-export.service"; export * from "./services/individual-vault-export.service.abstraction"; export * from "./services/individual-vault-export.service"; +export * from "./services/export-helper";