1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 03:03:26 +00:00

Add CSV export functionality to organization members page (#17342)

* Add CSV export functionality to organization members page

* Remove unnecessary async from getMemberExport method

* Changed button position and style

* fixed button alignment

* updates based on feedback from product design

* refactor, cleanup

* fix DI

* add default to user status pipe

* add missing i18n key

* copy update

* remove redundant copy

---------

Co-authored-by: Brandon <btreston@bitwarden.com>
This commit is contained in:
Max
2026-01-08 16:09:52 +01:00
committed by GitHub
parent 95235a2b7b
commit 0396b4e054
14 changed files with 416 additions and 37 deletions

View File

@@ -1 +1,2 @@
export * from "./members.module";
export * from "./pipes";

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

@@ -35,6 +35,7 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -55,7 +56,11 @@ import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
@@ -119,6 +124,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
@@ -593,4 +600,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("dataExportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
};
}

View File

@@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
MemberExportService,
} from "./services";
@NgModule({
@@ -45,12 +47,15 @@ import {
BulkStatusComponent,
MembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
MemberExportService,
UserStatusPipe,
],
})
export class MembersModule {}

View File

@@ -0,0 +1 @@
export * from "./user-status.pipe";

View File

@@ -0,0 +1,47 @@
import { MockProxy, mock } from "jest-mock-extended";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserStatusPipe } from "./user-status.pipe";
describe("UserStatusPipe", () => {
let pipe: UserStatusPipe;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
pipe = new UserStatusPipe(i18nService);
});
it("transforms OrganizationUserStatusType.Invited to 'invited'", () => {
expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited");
expect(i18nService.t).toHaveBeenCalledWith("invited");
});
it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => {
expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted");
expect(i18nService.t).toHaveBeenCalledWith("accepted");
});
it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => {
expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed");
expect(i18nService.t).toHaveBeenCalledWith("confirmed");
});
it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => {
expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked");
expect(i18nService.t).toHaveBeenCalledWith("revoked");
});
it("transforms null to 'unknown'", () => {
expect(pipe.transform(null)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
it("transforms undefined to 'unknown'", () => {
expect(pipe.transform(undefined)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
});

View File

@@ -0,0 +1,30 @@
import { Pipe, PipeTransform } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Pipe({
name: "userStatus",
standalone: false,
})
export class UserStatusPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserStatusType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserStatusType.Invited:
return this.i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return this.i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return this.i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return this.i18nService.t("revoked");
default:
return this.i18nService.t("unknown");
}
}
}

View File

@@ -1,4 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { MemberExportService } from "./member-export";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";

View File

@@ -0,0 +1,2 @@
export * from "./member.export";
export * from "./member-export.service";

View File

@@ -0,0 +1,151 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
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> = {
// Column headers
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
// Status values
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
// Role values
owner: "Owner",
admin: "Admin",
user: "User",
custom: "Custom",
// Boolean states
enabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
};
return translations[key] || key;
});
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
],
});
service = TestBed.inject(MemberExportService);
});
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 handle empty members array", () => {
const csvData = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
});
});
});

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import * as papa from "papaparse";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExport } from "./member.export";
@Injectable()
export class MemberExportService {
private i18nService = inject(I18nService);
private userTypePipe = inject(UserTypePipe);
private userStatusPipe = inject(UserStatusPipe);
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
return papa.unparse(exportData, {
columns: headers,
header: true,
});
}
getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -0,0 +1,43 @@
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
export class MemberExport {
/**
* @param user Organization user to export
* @returns a Record<string, string> of each column header key, value
* All property members must be a string for export purposes. Null and undefined will appear as
* "null" in a .csv export, therefore an empty string is preferable to a nullish type.
*/
static fromOrganizationUserView(
i18nService: I18nService,
userTypePipe: UserTypePipe,
userStatusPipe: UserStatusPipe,
user: OrganizationUserView,
): Record<string, string> {
const result = {
[i18nService.t("email")]: user.email,
[i18nService.t("name")]: user.name ?? "",
[i18nService.t("status")]: userStatusPipe.transform(user.status),
[i18nService.t("role")]: userTypePipe.transform(user.type),
[i18nService.t("twoStepLogin")]: user.twoFactorEnabled
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("accountRecovery")]: user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled"),
[i18nService.t("secretsManager")]: user.accessSecretsManager
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("groups")]: user.groupNames?.join(", ") ?? "",
};
return result;
}
}

View File

@@ -4,6 +4,7 @@ import { Injectable } from "@angular/core";
import * as papa from "papaparse";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { EventExport } from "./event.export";
@@ -16,25 +17,6 @@ export class EventExportService {
}
getFileName(prefix: string = 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;
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

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."
},
@@ -2537,6 +2540,9 @@
"enabled": {
"message": "Turned on"
},
"optionEnabled": {
"message": "Enabled"
},
"restoreAccess": {
"message": "Restore access"
},
@@ -5649,6 +5655,9 @@
"revoked": {
"message": "Revoked"
},
"accepted": {
"message": "Accepted"
},
"sendLink": {
"message": "Send link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -6307,6 +6316,12 @@
"enrolledAccountRecovery": {
"message": "Enrolled in account recovery"
},
"enrolled": {
"message": "Enrolled"
},
"notEnrolled": {
"message": "Not enrolled"
},
"withdrawAccountRecovery": {
"message": "Withdraw from account recovery"
},