Add bulk user actions (Import/Export/Restore) (#741)

* Add "Bulk Actions" modal to users page

* Add control logic without validation

* Add restore and better permission check

* Add controller logic and automatic backup

* Add CTL commands for bulk actions

* Add tests for bulk actions

* Improve description on permissions
This commit is contained in:
Cooldude2606 authored and GitHub committed 2025-04-06 01:42:51 +01:00
1 parent 1d5040dc0c
commit 0055f28f45
8 files changed
+1044 -5

No files matched your search

@@ -116,6 +116,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this));
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this));
this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -752,6 +754,209 @@ export default class ControlConnection extends BaseConnection {
}
}
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
if (request.importType === "users") {
// Update all fields for all users
const users = request.users as Extract<typeof request.users, { username: string }[]>;
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
for (const user of this._controller.userManager.users.values()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
}
if (user.isBanned && !banned.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
if (user.isWhitelisted && !whitelist.has(user.name)) {
user.isWhitelisted = false;
updated.set(user.id, user);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
}
} else {
// Whitelist and admin have the same logic
const users = request.users as string[];
const expected = new Set(users.map(u => u.toLowerCase()));
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.userManager.users.values()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
}
}
}
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
// eslint-disable-next-line complexity
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<ControllerUser["id"], ControllerUser>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
await this.handleUserBulkRestoreRequest(request, updated);
}
// Will get a user or attempt to create one
const getUserOrCreate = (username: string) => {
const user = this._controller.userManager.getByName(username);
if (user) { return user; }
this.user.checkPermission("core.user.create");
return this._controller.userManager.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
if (request.importType === "users") {
// Update all fields
const users = request.users as Extract<typeof request.users, { username: string }[]>;
for (const user of users) {
const cUser = getUserOrCreate(user.username);
if (user.is_admin && !cUser.isAdmin) {
cUser.isAdmin = true;
updated.set(cUser.id, cUser);
}
if (user.is_banned && !cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.ban_reason ?? "";
updated.set(cUser.id, cUser);
}
if (user.is_whitelisted && !cUser.isWhitelisted) {
cUser.isWhitelisted = true;
updated.set(cUser.id, cUser);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
for (const user of users) {
if (typeof user === "string") {
const cUser = getUserOrCreate(user);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = "";
updated.set(cUser.id, cUser);
}
} else {
const cUser = getUserOrCreate(user.username);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.reason;
updated.set(cUser.id, cUser);
}
}
}
} else {
// Whitelist and admin have the same logic
const users = request.users as string[];
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of users) {
const cUser = getUserOrCreate(user);
if (!cUser[prop]) {
cUser[prop] = true;
updated.set(cUser.id, cUser);
}
}
}
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.usersUpdated(updatedUsers);
// Resync the host user lists
const adminlist: Set<string> = new Set();
const banlist: Map<string, string> = new Map();
const whitelist: Set<string> = new Set();
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.id);
}
}
this._controller.sendEvent(
new lib.SyncUserListsEvent(adminlist, banlist, whitelist),
lib.Address.fromShorthand("allHosts")
);
return backup ?? [];
}
async handleUserBulkExportRequest(request: lib.UserBulkExportRequest) {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.userManager.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
send = true;
userToSend.is_banned = true;
if (user.banReason !== "") {
userToSend.ban_reason = user.banReason;
}
}
if (user.isAdmin) {
send = true;
userToSend.is_admin = true;
}
if (user.isWhitelisted) {
send = true;
userToSend.is_whitelisted = true;
}
if (send) {
usersToSend.push(userToSend);
}
}
return new lib.ClusterioUserExport(usersToSend);
} else if (request.exportType === "bans") {
// Bans have extra logic to handle ban reason
const usersToSend = [] as Array<string | { username: string, reason: string }>;
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
} else {
usersToSend.push(user.name);
}
}
}
return usersToSend;
}
// This is a whitelist or admins export
const usersToSend = [] as string[];
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.userManager.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
}
return usersToSend;
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
this.ws_dumper = data => {
if (this.connector.connected) {
+116
View File
@@ -1836,6 +1836,122 @@ userCommands.add(new lib.Command({
},
}));
async function handleImportOrRestore(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, restore: boolean) {
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
const filename = path.basename(args.filepath);
if (args.users || (optionCount === 0 && data.export_version)) {
importType = "users";
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
importType = "bans";
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
importType = "admins";
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
importType = "whitelist";
} else {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
userCommands.add(new lib.Command({
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
userCommands.add(new lib.Command({
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
},
}));
userCommands.add(new lib.Command({
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
yargs.options({
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
const optionCount = [args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
if (args.users) {
exportType = "users";
} else if (args.bans) {
exportType = "bans";
} else if (args.admins) {
exportType = "admins";
} else if (args.whitelist) {
exportType = "whitelist";
}
fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
+129 -2
View File
@@ -1,8 +1,9 @@
import { Type, Static } from "@sinclair/typebox";
import Permission from "./Permission";
import Role from "./Role";
import User from "./User";
import { JsonNumber, jsonArray } from "./composites";
import User, { IControllerUser } from "./User";
import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
@@ -320,3 +321,129 @@ export class UserUpdatesEvent {
return new this(json.updates.map(update => User.fromJSON(update)));
}
}
export class ClusterioUserExport {
public export_version = "2.0.0-alpha.20";
static clusterioUserSchema = Type.Object({
"username": Type.String(),
"is_admin": Type.Optional(Type.Boolean()),
"is_whitelisted": Type.Optional(Type.Boolean()),
"is_banned": Type.Optional(Type.Boolean()),
"ban_reason": Type.Optional(Type.String()),
});
static factorioUserSchema = Type.Union([
Type.String(),
Type.Object({
"username": Type.String(),
"reason": Type.String(),
}),
]);
static jsonSchema = Type.Object({
"export_version": Type.String(),
"users": Type.Array(this.clusterioUserSchema),
});
constructor(
public users: Static<typeof ClusterioUserExport.jsonSchema.properties.users>,
) { }
static fromJSON(json: Static<typeof this.jsonSchema>) {
const obj = new this(json.users);
obj.export_version = json.export_version;
return obj;
}
}
export class UserBulkImportRequest {
declare ["constructor"]: typeof UserBulkImportRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof UserBulkImportRequest.jsonSchema>;
// Check if this is a restore or import request
if (data.restore) {
user.checkPermission("core.user.bulk_restore");
} else {
user.checkPermission("core.user.bulk_import");
}
// Check if they have the permission for that type
switch (data.importType) {
case "users":
user.checkPermission("core.user.set_admin");
user.checkPermission("core.user.set_banned");
user.checkPermission("core.user.set_whitelisted");
break;
case "admins":
user.checkPermission("core.user.set_admin");
break;
case "bans":
user.checkPermission("core.user.set_banned");
break;
case "whitelist":
user.checkPermission("core.user.set_whitelisted");
break;
default:
// @ts-expect-error Unreachable
throw new Error(`Unknown import / restore type: ${data.importType}`);
}
}
}
static Response = plainJson(Type.Union([
Type.Array(ClusterioUserExport.factorioUserSchema),
ClusterioUserExport.jsonSchema,
]));
constructor(
public importType: "users" | "bans" | "admins" | "whitelist",
public users: Static<typeof ClusterioUserExport.clusterioUserSchema>[]
| Static<typeof ClusterioUserExport.factorioUserSchema>[],
public restore?: boolean
) { }
static jsonSchema = Type.Union([
Type.Object({
"importType": StringEnum(["bans", "admins", "whitelist"]),
"users": Type.Array(ClusterioUserExport.factorioUserSchema),
"restore": Type.Optional(Type.Boolean()),
}),
Type.Object({
"importType": Type.Literal("users"),
"users": Type.Array(ClusterioUserExport.clusterioUserSchema),
"restore": Type.Optional(Type.Boolean()),
}),
]);
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.importType, json.users, json.restore);
}
}
export class UserBulkExportRequest {
declare ["constructor"]: typeof UserBulkExportRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.bulk_export" as const;
static Response = plainJson(Type.Union([
Type.Array(ClusterioUserExport.factorioUserSchema),
ClusterioUserExport.jsonSchema,
]));
constructor(
public exportType: "users" | "bans" | "admins" | "whitelist",
) { }
static jsonSchema = Type.Object({
"exportType": StringEnum(["users", "bans", "admins", "whitelist"]),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.exportType);
}
}
+2
View File
@@ -118,4 +118,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
user.UserSetBannedRequest,
user.UserDeleteRequest,
user.UserUpdatesEvent,
user.UserBulkImportRequest,
user.UserBulkExportRequest,
];
+18
View File
@@ -484,6 +484,24 @@ definePermission({
title: "Delete user",
description: "Delete users and all data stored for them.",
});
definePermission({
name: "core.user.bulk_import",
title: "Bulk user import",
description: "Bulk import users including admin, whitelist, and bans." +
" (Imports types are restricted by other permissions, e.g. core.user.set_admin)",
});
definePermission({
name: "core.user.bulk_export",
title: "Bulk user export",
description: "Bulk export users including admin, whitelist, and bans.",
});
definePermission({
name: "core.user.bulk_restore",
title: "Bulk user restore",
description: "Bulk restore users including admin, whitelist, and bans." +
" (Restore types are restricted by other permissions, e.g. core.user.set_admin)",
});
definePermission({
name: "core.log.follow",
title: "Follow cluster log",
@@ -24,6 +24,8 @@ function isActiveDropzone(element: HTMLElement | null): boolean {
while (element && depth < checkDepth) {
if (element.classList.contains("dropzone")) {
return element.classList.contains("enabled");
} else if (element.classList.contains("ant-upload-drag")) {
return !element.classList.contains("disabled");
}
element = element.parentElement;
depth += 1;
+329 -3
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Space, Table, Tag } from "antd";
import { Button, Form, FormInstance, GetProp, Input, Modal, Radio, Space, Table, Tag, Upload, UploadProps } from "antd";
import { InboxOutlined } from "@ant-design/icons";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
@@ -16,7 +18,6 @@ import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function CreateUserButton() {
let control = useContext(ControlContext);
let navigate = useNavigate();
@@ -57,6 +58,327 @@ function CreateUserButton() {
</>;
}
// This is the most common and best supported method
// For full support we should consider using npm:file-saver
function saveFile(name: string, blob: Blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.addEventListener("click", (e) => {
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
a.click();
};
function saveJson(name: string, json: object) {
return saveFile(name, new Blob([JSON.stringify(json, null, 2)], { type: "application/json" }));
}
type UserBulkActionProps = {
setApplyAction(func: () => Promise<void>): void,
form: FormInstance,
restore?: boolean,
}
function UserBulkActionImport({ setApplyAction, form, restore }: UserBulkActionProps) {
const control = useContext(ControlContext);
const account = useAccount();
// Combines an array of users into the main array to be sent to the controller
function combineMixed(
prop: "is_admin" | "is_banned" | "is_whitelisted",
users: Array<Static<typeof lib.ClusterioUserExport.factorioUserSchema>>,
usersToSend: Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>
) {
for (const user of users) {
if (typeof user !== "string") {
// It can only be a ban in this case
const existingUser = usersToSend.get(user.username);
if (existingUser) {
existingUser.is_banned = true;
existingUser.ban_reason = existingUser.ban_reason ? existingUser.ban_reason : user.reason;
} else {
usersToSend.set(user.username, {
is_banned: true,
...user,
});
}
} else {
// We only have the username
const existingUser = usersToSend.get(user);
if (existingUser) {
existingUser[prop] = true;
} else {
usersToSend.set(user, {
username: user,
[prop]: true,
});
}
}
}
}
// Parse a user json and combine into the format to be sent to the controller
function parseUsers(
users: Array<Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>,
usersToSend: Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>
) {
for (const user of users) {
const existingUser = usersToSend.get(user.username);
// Combine all fields with an existing user
if (existingUser) {
existingUser.is_admin ||= user.is_admin;
existingUser.is_banned ||= user.is_banned;
existingUser.is_whitelisted ||= user.is_whitelisted;
existingUser.ban_reason = existingUser.ban_reason ? existingUser.ban_reason : user.ban_reason;
}
// We do not send false values
if (user.is_admin === false) {
delete user.is_admin;
}
if (user.is_banned === false) {
delete user.is_banned;
delete user.ban_reason;
}
if (user.is_whitelisted === false) {
delete user.is_whitelisted;
}
usersToSend.set(user.username, user);
}
}
// Parse a ban json and combine into the format to be sent to the controller
function parseBans(
users: Array<Static<typeof lib.ClusterioUserExport.factorioUserSchema>>,
usersToSend: Map<string, string | { username: string, reason: string }>
) {
for (const user of users) {
if (typeof user === "string") {
if (!usersToSend.has(user)) {
usersToSend.set(user, user);
}
} else if (!user.reason) {
if (!usersToSend.has(user.username)) {
usersToSend.set(user.username, user.username);
}
} else {
usersToSend.set(user.username, user);
}
}
}
// Parse an admin or whitelist json and combine into the format to be sent to the controller
function parseAdminsWhitelist(
users: Array<string>,
usersToSend: Set<string>,
) {
for (const user of users) {
usersToSend.add(user);
}
}
setApplyAction(async () => {
let backup: object | undefined;
const values = form.getFieldsValue();
const importType = restore ? values.restoreType : values.importType;
switch (importType) {
case "mixed": {
// Parse and combine multiple json types then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>();
for (const file of values.fileList as File[]) {
const json = JSON.parse(await file.text());
if (json.export_version) {
parseUsers(json.users, usersToSend);
} else if (file.name.includes("ban")) {
const users = new Map();
parseBans(json, users);
combineMixed("is_banned", [...users.values()], usersToSend);
} else if (file.name.includes("admin")) {
const users = new Set<string>();
parseAdminsWhitelist(json, users);
combineMixed("is_admin", [...users.values()], usersToSend);
} else if (file.name.includes("whitelist")) {
const users = new Set<string>();
parseAdminsWhitelist(json, users);
combineMixed("is_whitelisted", [...users.values()], usersToSend);
} else {
throw new Error(`Unknown json (could not guess by file name): ${file.name}`);
}
}
backup = await control.send(new lib.UserBulkImportRequest("users", [...usersToSend.values()], restore));
break;
}
case "users": {
// Parse and user jsons then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>();
for (const file of values.fileList as File[]) {
parseUsers(JSON.parse(await file.text()).users, usersToSend);
}
backup = await control.send(new lib.UserBulkImportRequest("users", [...usersToSend.values()], restore));
break;
}
case "bans": {
// Parse and combine ban jsons then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.factorioUserSchema>>();
for (const file of values.fileList as File[]) {
parseBans(JSON.parse(await file.text()), usersToSend);
}
backup = await control.send(new lib.UserBulkImportRequest("bans", [...usersToSend.values()], restore));
break;
}
case "admins":
case "whitelist": {
// Parse and combine admin or whitelist jsons then send
const usersToSend = new Set<string>();
for (const file of values.fileList as File[]) {
parseAdminsWhitelist(JSON.parse(await file.text()), usersToSend);
}
backup = await control.send(
new lib.UserBulkImportRequest(importType, [...usersToSend.keys()], restore)
);
break;
}
default: {
// Should be unreachable
throw new Error(`Unknown importType: ${importType}`);
}
}
if (restore && backup) {
saveJson(`${importType}-backup.json`, backup);
}
});
const normaliseFiles = (e: any) => (Array.isArray(e) ? e : e.fileList).map((f: any) => f.originFileObj);
const uploadProps: UploadProps = {
accept: ".json",
multiple: true,
beforeUpload: (file) => false,
};
return <>
<Form.Item
label="Type"
name={restore ? "restoreType" : "importType"}
initialValue={restore ? "users" : "mixed"}
>
<Radio.Group>
{restore ? undefined : <Radio.Button value="mixed" disabled={!account.hasAnyPermission(
"core.user.set_admin", "core.user.set_banned", "core.user.set_whitelisted"
)}>Mixed</Radio.Button>}
<Radio.Button value="users" disabled={!account.hasAllPermission(
"core.user.set_admin", "core.user.set_banned", "core.user.set_whitelisted"
)}>Users</Radio.Button>
<Radio.Button value="admins" disabled={!account.hasPermission(
"core.user.set_admin"
)}>Admins</Radio.Button>
<Radio.Button value="bans" disabled={!account.hasPermission(
"core.user.set_banned"
)}>Bans</Radio.Button>
<Radio.Button value="whitelist" disabled={!account.hasPermission(
"core.user.set_whitelisted"
)}>Whitelist</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name="fileList" valuePropName="fileList" getValueFromEvent={normaliseFiles} noStyle>
<Upload.Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Click or drag file to this area to import</p>
<p className="ant-upload-hint">Support for a single or bulk import.</p>
</Upload.Dragger>
</Form.Item>
</>;
}
function UserBulkActionExport({ setApplyAction, form }: UserBulkActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
const values = form.getFieldsValue();
const response = await control.send(new lib.UserBulkExportRequest(values.exportType));
switch (values.exportType) {
case "users": return saveJson("clusterio-userlist.json", response);
case "admins": return saveJson("server-adminlist.json", response);
case "bans": return saveJson("server-banlist.json", response);
case "whitelist": return saveJson("server-whitelist.json", response);
default: throw new Error(`Unknown exportType: ${values.importType}`);
}
});
return <>
<Form.Item label="Type" name="exportType" initialValue="users">
<Radio.Group>
<Radio.Button value="users">Users</Radio.Button>
<Radio.Button value="admins">Admins</Radio.Button>
<Radio.Button value="bans">Bans</Radio.Button>
<Radio.Button value="whitelist">Whitelist</Radio.Button>
</Radio.Group>
</Form.Item>
</>;
}
function BulkUserActionButton() {
const account = useAccount();
const [open, setOpen] = useState(false);
const [formAction, setFormAction] = useState<string | undefined>(undefined);
const [form] = Form.useForm();
function onValuesChange({ action } : { action?: string }) {
if (action) {
setFormAction(action);
}
}
let applyAction: () => Promise<void>;
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
async function onOk() {
if (!applyAction) {
form.setFields([{ name: "action", errors: ["Action is required"] }]);
return;
}
await applyAction();
setFormAction(undefined);
setOpen(false);
}
return <>
<Button
type="default"
onClick={() => { setOpen(true); }}
>Bulk Actions</Button>
<Modal
title="Bulk Actions"
okText="Apply"
open={open}
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission("core.user.bulk_import")
? <Radio.Button value="import">Import</Radio.Button> : undefined}
{account.hasPermission("core.user.bulk_export")
? <Radio.Button value="export">Export</Radio.Button> : undefined}
{account.hasPermission("core.user.bulk_restore")
? <Radio.Button value="restore">Restore</Radio.Button> : undefined}
</Radio.Group>
</Form.Item>
{formAction === "import" ? <UserBulkActionImport {...{setApplyAction, form}}/> : undefined}
{formAction === "export" ? <UserBulkActionExport {...{setApplyAction, form}}/> : undefined}
{formAction === "restore" ? <UserBulkActionImport restore {...{setApplyAction, form}}/> : undefined}
</Form>
</Modal>
</>;
}
export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
@@ -76,7 +398,11 @@ export default function UsersPage() {
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
extra={<>
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
? <BulkUserActionButton /> : undefined}
</>}
/>
<Table
columns={[
+243
View File
@@ -1397,5 +1397,248 @@ describe("Integration of Clusterio", function() {
assert.equal(getControl().userUpdates[0].isDeleted, true);
});
});
describe("user import", function() {
it("should import users", async function() {
await fs.writeJSON(path.join("temp", "test", "import-users.json"), {
export_version: "2.0.0.alpha20",
users: [
{ username: "import_user_admin", is_admin: true },
{ username: "import_user_banned", is_banned: true },
{ username: "import_user_ban_reason", is_banned: true, ban_reason: "banned" },
{ username: "import_user_whitelist", is_whitelisted: true },
{ username: "import_user_none" },
],
});
await execCtl("user import --users import-users.json");
const users = await getUsers();
assert(users.get("import_user_admin").isAdmin, "import_user_admin was not admin");
assert(users.get("import_user_banned").isBanned, "import_user_banned was not banned");
assert(
users.get("import_user_whitelist").isWhitelisted,
"import_user_whitelist was not whitelisted"
);
const userNone = users.get("import_user_none");
assert(!userNone.isAdmin, "import_user_none was admin");
assert(!userNone.isBanned, "import_user_none was banned");
assert(!userNone.isWhitelisted, "import_user_none was whitelisted");
const userBanReason = users.get("import_user_ban_reason");
assert(userBanReason.isBanned, "import_user_ban_reason was not banned");
assert.equal(userBanReason.banReason, "banned", "import_user_ban_reason had incorrect ban reason");
});
it("should import users by json format", async function() {
await fs.writeJSON(path.join("temp", "test", "import-users.json"), {
export_version: "2.0.0.alpha20",
users: [{ username: "import_user", is_admin: true, is_whitelisted: true }],
});
await execCtl("user import import-users.json");
const userFoo = (await getUsers()).get("import_user");
assert(userFoo.isAdmin, "import_user was not admin");
assert(userFoo.isWhitelisted, "import_user was not whitelisted");
});
it("should import bans", async function() {
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
{ username: "import_ban_reason", reason: "banned" },
"import_ban",
]);
await execCtl("user import --bans import-bans.json");
const users = await getUsers();
const userFoo = users.get("import_ban_reason");
assert(userFoo.isBanned, "import_ban_reason was not banned");
assert.equal(userFoo.banReason, "banned", "import_ban_reason had incorrect ban reason");
const userBar = users.get("import_ban");
assert(userBar.isBanned, "import_ban was not banned");
assert.equal(userBar.banReason, "", "import_ban had incorrect ban reason");
});
it("should import bans by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
"import_ban_filename",
]);
await execCtl("user import import-bans.json");
assert((await getUsers()).get("import_ban_filename").isBanned, "import_ban_filename was not banned");
});
it("should import admins", async function() {
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
"import_admin",
]);
await execCtl("user import --admins import-admins.json");
assert((await getUsers()).get("import_admin").isAdmin, "import_admin was not admin");
});
it("should import admins by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
"import_admin_filename",
]);
await execCtl("user import import-admins.json");
assert((await getUsers()).get("import_admin_filename").isAdmin, "import_admin_filename was not admin");
});
it("should import whitelist", async function() {
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
"import_whitelist",
]);
await execCtl("user import --whitelist import-whitelist.json");
assert(
(await getUsers()).get("import_whitelist").isWhitelisted,
"import_whitelist was not whitelisted"
);
});
it("should import whitelist by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
"import_whitelist_filename",
]);
await execCtl("user import import-whitelist.json");
assert(
(await getUsers()).get("import_whitelist_filename").isWhitelisted,
"import_whitelist_filename was not whitelisted"
);
});
it("should reject multiple provided options", async function() {
assert.rejects(execCtl("user import --bans --admins import-admins.json"));
});
});
describe("user export", function() {
before(async function() {
await execCtl("user create export_control");
});
it("should export users", async function() {
slowTest(this);
await execCtl("user set-admin export_user --create");
await execCtl("user set-whitelisted export_user");
await execCtl("user export --users user-export.json");
const data = await fs.readJson(path.join("temp", "test", "user-export.json"));
assert(data.users, "Users array does not exist");
assert(!data.users.find(u => u.username === "export_control"), "Control user is present");
assert.deepEqual(data.users.find(u => u.username === "export_user"), {
username: "export_user", is_admin: true, is_whitelisted: true,
});
});
it("should export bans", async function() {
slowTest(this);
await execCtl("user set-banned export_ban --create");
await execCtl("user set-banned export_ban_reason --create --reason banned");
await execCtl("user export --bans ban-export.json");
const data = await fs.readJson(path.join("temp", "test", "ban-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_ban") >= 0, "export_ban is missing");
assert.deepEqual(data.find(u => typeof u === "object" && u.username === "export_ban_reason"), {
username: "export_ban_reason", reason: "banned",
});
});
it("should export admins", async function() {
await execCtl("user set-admin export_admin --create");
await execCtl("user export --admins admin-export.json");
const data = await fs.readJson(path.join("temp", "test", "admin-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_admin") >= 0, "export_admin is missing");
});
it("should export whitelist", async function() {
await execCtl("user set-whitelisted export_whitelist --create");
await execCtl("user export --whitelist whitelist-export.json");
const data = await fs.readJson(path.join("temp", "test", "whitelist-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_whitelist") >= 0, "export_whitelist is missing");
});
});
describe("user restore", function() {
beforeEach(async function() {
slowTest(this);
await execCtl("user set-admin restore_control --create");
await execCtl("user set-banned restore_control");
await execCtl("user set-whitelisted restore_control");
});
it("should restore users", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-users.json"), {
export_version: "2.0.0.alpha20",
users: [{ username: "restore_user", is_admin: true, is_whitelisted: true }],
});
await execCtl("user restore restore-users.json");
const users = await getUsers();
const user = users.get("restore_user");
assert(user.isAdmin, "restore_user was not admin");
assert(user.isWhitelisted, "restore_user was not whitelisted");
const control = users.get("restore_control");
assert(!control.isAdmin, "restore_user was admin");
assert(!control.isBanned, "restore_user was banned");
assert(!control.isWhitelisted, "restore_user was whitelisted");
const data = await fs.readJson(path.join("temp", "test", "users-backup.json"));
assert(data.users, "Users array does not exist");
assert.deepEqual(data.users.find(u => u.username === "restore_control"), {
username: "restore_control", is_admin: true, is_banned: true, is_whitelisted: true,
});
});
it("should restore bans", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-bans.json"), [
{ username: "restore_ban_reason", reason: "banned" },
"restore_ban",
]);
await execCtl("user restore --bans restore-bans.json");
const users = await getUsers();
const userFoo = users.get("restore_ban_reason");
assert(userFoo.isBanned, "restore_ban_reason was not banned");
assert.equal(userFoo.banReason, "banned", "restore_ban_reason had incorrect ban reason");
const userBar = users.get("restore_ban");
assert(userBar.isBanned, "restore_ban was not banned");
assert.equal(userBar.banReason, "", "restore_ban had incorrect ban reason");
assert(!users.get("restore_control").isBanned, "restore_user was banned");
const data = await fs.readJson(path.join("temp", "test", "bans-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should restore admins", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-admins.json"), [
"restore_admin",
]);
await execCtl("user restore --admins restore-admins.json");
const users = await getUsers();
assert(users.get("restore_admin").isAdmin, "restore_admin was not admin");
assert(!users.get("restore_control").isAdmin, "restore_user was admin");
const data = await fs.readJson(path.join("temp", "test", "admins-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should restore whitelist", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-whitelist.json"), [
"restore_whitelist",
]);
await execCtl("user restore --whitelist restore-whitelist.json");
const users = await getUsers();
assert(users.get("restore_whitelist").isWhitelisted, "restore_whitelist was not whitelisted");
assert(!users.get("restore_control").isWhitelisted, "restore_user was whitelisted");
const data = await fs.readJson(path.join("temp", "test", "whitelist-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should reject multiple provided options", async function() {
assert.rejects(execCtl("user restore --bans --admins restore-admins.json"));
});
});
});
});