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:
@@ -1 +1,2 @@
|
||||
export * from "./members.module";
|
||||
export * from "./pipes";
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-status.pipe";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./member.export";
|
||||
export * from "./member-export.service";
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user