mirror of
https://github.com/bitwarden/browser.git
synced 2025-12-05 09:14:28 +01:00
Merge 593863b8d1 into d32365fbba
This commit is contained in:
commit
75dc7dd523
@ -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()">
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user