1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00
This commit is contained in:
Maximilian Power 2025-12-04 18:39:04 -06:00 committed by GitHub
commit 75dc7dd523
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 457 additions and 16 deletions

View File

@ -102,15 +102,25 @@
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
@ -352,13 +362,16 @@
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">

View File

@ -33,6 +33,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@ -55,6 +56,7 @@ import {
MemberActionsService,
MemberActionResult,
} from "./services/member-actions/member-actions.service";
import { MemberExportService } from "./services/member-export.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
@ -113,6 +115,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
) {
super(
apiService,
@ -544,4 +548,36 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
exportMembers = async (): Promise<void> => {
try {
const members = this.dataSource.data;
if (!members || members.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noMembersToExport"),
});
return;
}
const csvData = this.memberExportService.getMemberExport(members);
const fileName = this.memberExportService.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("exportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
};
}

View File

@ -0,0 +1,253 @@
import { MockProxy, mock } from "jest-mock-extended";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../core/views/organization-user.view";
import { MemberExportService } from "./member-export.service";
describe("MemberExportService", () => {
let service: MemberExportService;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
// Setup common i18n translations
i18nService.t.mockImplementation((key: string) => {
const translations: Record<string, string> = {
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
owner: "Owner",
admin: "Admin",
user: "User",
custom: "Custom",
enabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
};
return translations[key] || key;
});
service = new MemberExportService(i18nService);
});
describe("getMemberExport", () => {
it("should export members with all fields populated", () => {
const members: OrganizationUserView[] = [
{
email: "user1@example.com",
name: "User One",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
twoFactorEnabled: true,
resetPasswordEnrolled: true,
accessSecretsManager: true,
groupNames: ["Group A", "Group B"],
} as OrganizationUserView,
{
email: "user2@example.com",
name: "User Two",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Group C"],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
expect(csvData).toContain("user1@example.com");
expect(csvData).toContain("User One");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Admin");
expect(csvData).toContain("user2@example.com");
expect(csvData).toContain("User Two");
expect(csvData).toContain("Invited");
});
it("should handle members with null name", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: null,
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: [],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
// Empty name is represented as an empty field in CSV
expect(csvData).toContain("user@example.com,,Confirmed");
});
it("should handle members with no groups", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: null,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
expect(csvData).toBeDefined();
});
it("should export all status types correctly", () => {
const members: OrganizationUserView[] = [
{
email: "invited@example.com",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
} as OrganizationUserView,
{
email: "accepted@example.com",
status: OrganizationUserStatusType.Accepted,
type: OrganizationUserType.User,
} as OrganizationUserView,
{
email: "confirmed@example.com",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
} as OrganizationUserView,
{
email: "revoked@example.com",
status: OrganizationUserStatusType.Revoked,
type: OrganizationUserType.User,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Invited");
expect(csvData).toContain("Accepted");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Revoked");
});
it("should export all role types correctly", () => {
const members: OrganizationUserView[] = [
{
email: "owner@example.com",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Owner,
} as OrganizationUserView,
{
email: "admin@example.com",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
} as OrganizationUserView,
{
email: "user@example.com",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
} as OrganizationUserView,
{
email: "custom@example.com",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Custom,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Owner");
expect(csvData).toContain("Admin");
expect(csvData).toContain("User");
expect(csvData).toContain("Custom");
});
it("should handle empty members array", () => {
const csvData = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
});
it("should format groups as comma-separated list", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Engineering", "Design", "Product"],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Engineering, Design, Product");
});
});
describe("getFileName", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2025-03-15T14:30:45"));
});
afterEach(() => {
jest.useRealTimers();
});
it("should generate filename with prefix and timestamp", () => {
const fileName = service.getFileName("org-members");
expect(fileName).toBe("bitwarden_org-members_export_20250315143045.csv");
});
it("should generate filename without prefix when null", () => {
const fileName = service.getFileName(null);
expect(fileName).toBe("bitwarden_export_20250315143045.csv");
});
it("should generate filename with custom extension", () => {
const fileName = service.getFileName("test", "txt");
expect(fileName).toBe("bitwarden_test_export_20250315143045.txt");
});
it("should pad single digit date components", () => {
jest.setSystemTime(new Date("2025-01-05T08:07:06"));
const fileName = service.getFileName("members");
expect(fileName).toBe("bitwarden_members_export_20250105080706.csv");
});
it("should use default csv extension when not specified", () => {
const fileName = service.getFileName("members");
expect(fileName).toContain(".csv");
});
});
});

View File

@ -0,0 +1,65 @@
import { Injectable } from "@angular/core";
import * as papa from "papaparse";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../core/views/organization-user.view";
import { MemberExport } from "./member.export";
@Injectable({
providedIn: "root",
})
export class MemberExportService {
constructor(private i18nService: I18nService) {}
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) => new MemberExport(m, this.i18nService));
const headers: { [key in keyof MemberExport]: string } = {
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
};
const mappedData = exportData.map((item) => {
const mappedItem: { [key: string]: string } = {};
for (const key in item) {
if (headers[key as keyof MemberExport]) {
mappedItem[headers[key as keyof MemberExport]] = String(item[key as keyof MemberExport]);
}
}
return mappedItem;
});
return papa.unparse(mappedData);
}
getFileName(prefix: string | null = null, extension = "csv"): string {
const now = new Date();
const dateString =
now.getFullYear() +
"" +
this.padNumber(now.getMonth() + 1, 2) +
"" +
this.padNumber(now.getDate(), 2) +
this.padNumber(now.getHours(), 2) +
"" +
this.padNumber(now.getMinutes(), 2) +
this.padNumber(now.getSeconds(), 2);
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension;
}
private padNumber(num: number, width: number, padCharacter = "0"): string {
const numString = num.toString();
return numString.length >= width
? numString
: new Array(width - numString.length + 1).join(padCharacter) + numString;
}
}

View File

@ -0,0 +1,65 @@
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../core/views/organization-user.view";
export class MemberExport {
email: string;
name: string;
status: string;
role: string;
twoStepLogin: string;
accountRecovery: string;
secretsManager: string;
groups: string;
constructor(user: OrganizationUserView, i18nService: I18nService) {
this.email = user.email;
this.name = user.name ?? "";
this.status = this.getStatusString(user.status, i18nService);
this.role = this.getRoleString(user.type, i18nService);
this.twoStepLogin = user.twoFactorEnabled
? i18nService.t("enabled")
: i18nService.t("disabled");
this.accountRecovery = user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled");
this.secretsManager = user.accessSecretsManager
? i18nService.t("enabled")
: i18nService.t("disabled");
this.groups = user.groupNames?.join(", ") ?? "";
}
private getStatusString(status: OrganizationUserStatusType, i18nService: I18nService): string {
switch (status) {
case OrganizationUserStatusType.Invited:
return i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return i18nService.t("revoked");
default:
return "";
}
}
private getRoleString(type: OrganizationUserType, i18nService: I18nService): string {
switch (type) {
case OrganizationUserType.Owner:
return i18nService.t("owner");
case OrganizationUserType.Admin:
return i18nService.t("admin");
case OrganizationUserType.User:
return i18nService.t("user");
case OrganizationUserType.Custom:
return i18nService.t("custom");
default:
return "";
}
}
}

View File

@ -1749,6 +1749,9 @@
"noMembersInList": {
"message": "There are no members to list."
},
"noMembersToExport": {
"message": "There are no members to export."
},
"noEventsInList": {
"message": "There are no events to list."
},
@ -6280,6 +6283,12 @@
"enrolledAccountRecovery": {
"message": "Enrolled in account recovery"
},
"enrolled": {
"message": "Enrolled"
},
"notEnrolled": {
"message": "Not enrolled"
},
"withdrawAccountRecovery": {
"message": "Withdraw from account recovery"
},