From 0055f28f45f076d1434c64fecc1f27b8cc67c5ac Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 6 Apr 2025 01:42:51 +0100 Subject: [PATCH] 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 --- packages/controller/src/ControlConnection.ts | 205 +++++++++++ packages/ctl/src/commands.ts | 116 ++++++ packages/lib/src/data/messages_user.ts | 131 ++++++- packages/lib/src/link/messages.ts | 2 + packages/lib/src/permissions.ts | 18 + packages/web_ui/src/components/SiteLayout.tsx | 2 + packages/web_ui/src/components/UsersPage.tsx | 332 +++++++++++++++++- test/integration/clusterio.js | 243 +++++++++++++ 8 files changed, 1044 insertions(+), 5 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 8ef23516..fe02d533 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -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) { + if (request.importType === "users") { + // Update all fields for all users + const users = request.users as Extract; + 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>; + const updated = new Map(); + 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; + 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 = new Set(); + const banlist: Map = new Map(); + const whitelist: Set = 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; + 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) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 76150cc3..c44d54b9 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -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[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 ", "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 ", "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 ", "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[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) => { diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index 17fcae89..93032cac 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -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, + ) { } + + static fromJSON(json: Static) { + 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; + // 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[] + | Static[], + 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) { + 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) { + return new this(json.exportType); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 4182db3a..7c0edbda 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -118,4 +118,6 @@ export const dataClasses: (RequestClass | EventClass) user.UserSetBannedRequest, user.UserDeleteRequest, user.UserUpdatesEvent, + user.UserBulkImportRequest, + user.UserBulkExportRequest, ]; diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 306047de..ff5443b8 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -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", diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index 381d23f7..bfd9f7e9 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -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; diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 73458a46..a47f4c79 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -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, + 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>, + usersToSend: Map> + ) { + 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>, + usersToSend: Map> + ) { + 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>, + usersToSend: Map + ) { + 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, + usersToSend: Set, + ) { + 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>(); + 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(); + parseAdminsWhitelist(json, users); + combineMixed("is_admin", [...users.values()], usersToSend); + } else if (file.name.includes("whitelist")) { + const users = new Set(); + 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>(); + 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>(); + 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(); + 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 <> + + + {restore ? undefined : Mixed} + Users + Admins + Bans + Whitelist + + + + +

+ +

+

Click or drag file to this area to import

+

Support for a single or bulk import.

+
+
+ ; +} + +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 <> + + + Users + Admins + Bans + Whitelist + + + ; +} + +function BulkUserActionButton() { + const account = useAccount(); + const [open, setOpen] = useState(false); + const [formAction, setFormAction] = useState(undefined); + const [form] = Form.useForm(); + + function onValuesChange({ action } : { action?: string }) { + if (action) { + setFormAction(action); + } + } + + let applyAction: () => Promise; + const setApplyAction = (func: () => Promise) => { applyAction = func; }; + async function onOk() { + if (!applyAction) { + form.setFields([{ name: "action", errors: ["Action is required"] }]); + return; + } + await applyAction(); + setFormAction(undefined); + setOpen(false); + } + + return <> + + { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} + onCancel={() => { setOpen(false); }} + destroyOnClose + > +
+ + + {account.hasPermission("core.user.bulk_import") + ? Import : undefined} + {account.hasPermission("core.user.bulk_export") + ? Export : undefined} + {account.hasPermission("core.user.bulk_restore") + ? Restore : undefined} + + + {formAction === "import" ? : undefined} + {formAction === "export" ? : undefined} + {formAction === "restore" ? : undefined} + +
+ ; +} + export default function UsersPage() { let account = useAccount(); let control = useContext(ControlContext); @@ -76,7 +398,11 @@ export default function UsersPage() { return : undefined} + extra={<> + {account.hasPermission("core.user.create") ? : undefined} + {account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export") + ? : undefined} + } /> 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")); + }); + }); }); });