@@ -352,13 +362,16 @@
|
-
+
diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts
index 51a2a6dafc0..e57cf54c180 100644
--- a/apps/web/src/app/admin-console/organizations/members/members.component.ts
+++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts
@@ -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
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
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
+
+ exportMembers = async (): Promise => {
+ 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}`);
+ }
+ };
}
diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts
index 3b233932ed3..65625cfd247 100644
--- a/apps/web/src/app/admin-console/organizations/members/members.module.ts
+++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts
@@ -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 {}
diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/index.ts b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts
new file mode 100644
index 00000000000..67c485ed361
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts
@@ -0,0 +1 @@
+export * from "./user-status.pipe";
diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts
new file mode 100644
index 00000000000..3fd05c8a2e8
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts
@@ -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;
+
+ beforeEach(() => {
+ i18nService = mock();
+ 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");
+ });
+});
diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts
new file mode 100644
index 00000000000..81590616027
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts
@@ -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");
+ }
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts
index baaa33eeae9..fd6b5816513 100644
--- a/apps/web/src/app/admin-console/organizations/members/services/index.ts
+++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts
@@ -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";
diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts
new file mode 100644
index 00000000000..acd36a91683
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts
@@ -0,0 +1,2 @@
+export * from "./member.export";
+export * from "./member-export.service";
diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts
new file mode 100644
index 00000000000..1e229b95d24
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts
@@ -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;
+
+ beforeEach(() => {
+ i18nService = mock();
+
+ // Setup common i18n translations
+ i18nService.t.mockImplementation((key: string) => {
+ const translations: Record = {
+ // 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("");
+ });
+ });
+});
diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts
new file mode 100644
index 00000000000..c00881617a4
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts
@@ -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);
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts
new file mode 100644
index 00000000000..262e8ebd9fb
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts
@@ -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 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 {
+ 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;
+ }
+}
diff --git a/apps/web/src/app/tools/event-export/event-export.service.ts b/apps/web/src/app/tools/event-export/event-export.service.ts
index f39b786b6d1..d888af51edf 100644
--- a/apps/web/src/app/tools/event-export/event-export.service.ts
+++ b/apps/web/src/app/tools/event-export/event-export.service.ts
@@ -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);
}
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index db30a9d1153..8024de21e56 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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"
},
|