mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[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
This commit is contained in:
187
apps/web/src/app/tools/reports/report-utils.spec.ts
Normal file
187
apps/web/src/app/tools/reports/report-utils.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/web/src/app/tools/reports/report-utils.ts
Normal file
71
apps/web/src/app/tools/reports/report-utils.ts
Normal file
@@ -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<T, K>(items: T[], keySelector: (item: T) => K): T[] {
|
||||||
|
const uniqueKeys = new Set<K>();
|
||||||
|
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<T>(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<T, K extends keyof T, V>(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<T>(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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
class="tw-grow"
|
class="tw-grow"
|
||||||
></bit-search>
|
></bit-search>
|
||||||
|
|
||||||
<button type="button" bitButton buttonType="primary">
|
<button type="button" bitButton buttonType="primary" [bitAction]="exportReportAction">
|
||||||
<span>{{ "export" | i18n }}</span>
|
<span>{{ "export" | i18n }}</span>
|
||||||
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
<ng-container header>
|
<ng-container header>
|
||||||
<tr>
|
<tr>
|
||||||
<th bitCell bitSortable="name" default>Members</th>
|
<th bitCell bitSortable="name" default>Members</th>
|
||||||
<th bitCell bitSortable="groups">Groups</th>
|
<th bitCell bitSortable="groupsCount">Groups</th>
|
||||||
<th bitCell bitSortable="collections">Collections</th>
|
<th bitCell bitSortable="collectionsCount">Collections</th>
|
||||||
<th bitCell bitSortable="items">Items</th>
|
<th bitCell bitSortable="itemsCount">Items</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template body let-rows$>
|
<ng-template body let-rows$>
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.groups }}</td>
|
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.groupsCount }}</td>
|
||||||
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.collections }}</td>
|
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.collectionsCount }}</td>
|
||||||
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.items }}</td>
|
<td bitCell class="tw-text-muted tw-w-[278px] tw-p-4">{{ r.itemsCount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
|||||||
@@ -1,33 +1,67 @@
|
|||||||
import { Component, OnInit } from "@angular/core";
|
import { Component, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormControl } from "@angular/forms";
|
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 { SearchModule, TableDataSource } from "@bitwarden/components";
|
||||||
|
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
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";
|
import { MemberAccessReportView } from "./view/member-access-report.view";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "member-access-report",
|
selector: "member-access-report",
|
||||||
templateUrl: "member-access-report.component.html",
|
templateUrl: "member-access-report.component.html",
|
||||||
imports: [SharedModule, SearchModule, HeaderModule],
|
imports: [SharedModule, SearchModule, HeaderModule],
|
||||||
|
providers: [
|
||||||
|
safeProvider({
|
||||||
|
provide: MemberAccessReportServiceAbstraction,
|
||||||
|
useClass: MemberAccessReportService,
|
||||||
|
deps: [MemberAccessReportApiService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class MemberAccessReportComponent implements OnInit {
|
export class MemberAccessReportComponent implements OnInit {
|
||||||
protected dataSource = new TableDataSource<MemberAccessReportView>();
|
protected dataSource = new TableDataSource<MemberAccessReportView>();
|
||||||
protected searchControl = new FormControl("", { nonNullable: true });
|
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
|
// Connect the search input to the table dataSource filter input
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||||
.subscribe((v) => (this.dataSource.filter = v));
|
.subscribe((v) => (this.dataSource.filter = v));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
async ngOnInit() {
|
||||||
this.dataSource.data = this.reportService.getMemberAccessMockData();
|
const params = await firstValueFrom(this.route.params);
|
||||||
|
this.organizationId = params.organizationId;
|
||||||
|
this.dataSource.data = this.reportService.generateMemberAccessReportView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportReportAction = async (): Promise<void> => {
|
||||||
|
this.fileDownloadService.download({
|
||||||
|
fileName: ExportHelper.getFileName("member-access"),
|
||||||
|
blobData: exportToCSV(
|
||||||
|
await this.reportService.generateUserReportExportItems(this.organizationId),
|
||||||
|
userReportItemHeaders,
|
||||||
|
),
|
||||||
|
blobOptions: { type: "text/plain" },
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MemberAccessExportItem[]>;
|
||||||
|
}
|
||||||
@@ -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 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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<MemberAccessReportApiService>();
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MemberAccessExportItem[]> {
|
||||||
|
const memberAccessReports = this.reportApiService.getMemberAccessData();
|
||||||
|
const userReportItemPromises = memberAccessReports.flatMap(async (memberAccessReport) => {
|
||||||
|
const partialMemberReportItem: Partial<MemberAccessExportItem> = {
|
||||||
|
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<MemberAccessExportItem>,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<MemberAccessExportItem[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export class MemberAccessReportView {
|
export type MemberAccessReportView = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
collections: number;
|
collectionsCount: number;
|
||||||
groups: number;
|
groupsCount: number;
|
||||||
items: number;
|
itemsCount: number;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./services/org-vault-export.service.abstraction";
|
|||||||
export * from "./services/org-vault-export.service";
|
export * from "./services/org-vault-export.service";
|
||||||
export * from "./services/individual-vault-export.service.abstraction";
|
export * from "./services/individual-vault-export.service.abstraction";
|
||||||
export * from "./services/individual-vault-export.service";
|
export * from "./services/individual-vault-export.service";
|
||||||
|
export * from "./services/export-helper";
|
||||||
|
|||||||
Reference in New Issue
Block a user