mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
The instance picker on the users page and the search box on the icon reference page held their value in component state, so leaving the page and coming back with the browser history reset them. Both now go through useTableQueryState like the table filters do, as user.instance and icon.search. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import React, { useContext, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Static } from "@sinclair/typebox";
|
|
|
|
import {
|
|
Button, Form, FormInstance, Input, Modal,
|
|
Radio, Space, Upload, UploadProps,
|
|
} from "antd";
|
|
|
|
import { InboxOutlined } from "@ant-design/icons";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import { useAccount } from "../model/account";
|
|
import ControlContext from "./ControlContext";
|
|
import { notifyErrorHandler } from "../util/notify";
|
|
import { saveJson } from "../util/save_file";
|
|
import PageHeader from "./PageHeader";
|
|
import PageLayout from "./PageLayout";
|
|
import PluginExtra from "./PluginExtra";
|
|
import UsersTable from "./UsersTable";
|
|
import { InputInstanceWithAll } from "./InputInstance";
|
|
import useTableQueryState from "../util/useTableQueryState";
|
|
|
|
function CreateUserButton() {
|
|
let control = useContext(ControlContext);
|
|
let navigate = useNavigate();
|
|
let [open, setOpen] = useState(false);
|
|
let [form] = Form.useForm();
|
|
|
|
async function createUser() {
|
|
let values = form.getFieldsValue();
|
|
if (!values.userName) {
|
|
form.setFields([{ name: "userName", errors: ["Name is required"] }]);
|
|
return;
|
|
}
|
|
|
|
await control.send(new lib.UserCreateRequest(values.userName));
|
|
setOpen(false);
|
|
navigate(`/users/${values.userName}/view`);
|
|
}
|
|
|
|
return <>
|
|
<Button
|
|
type="primary"
|
|
onClick={() => { setOpen(true); }}
|
|
>Create</Button>
|
|
<Modal
|
|
title="Create User"
|
|
okText="Create"
|
|
open={open}
|
|
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
|
onCancel={() => { setOpen(false); }}
|
|
destroyOnHidden
|
|
>
|
|
<Form form={form}>
|
|
<Form.Item name="userName" label="Name">
|
|
<Input />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>;
|
|
}
|
|
|
|
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); }}
|
|
destroyOnHidden
|
|
>
|
|
<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() {
|
|
const account = useAccount();
|
|
// Kept in the URL with the users table state so back/forward restores it
|
|
const tableState = useTableQueryState<lib.UserDetails>({ namespace: "user" });
|
|
const instanceParam = Number(tableState.filteredValue("instance")?.[0]);
|
|
const instanceId = Number.isInteger(instanceParam) ? instanceParam : null;
|
|
|
|
return <PageLayout nav={[{ name: "Users" }]}>
|
|
<PageHeader
|
|
title="Users"
|
|
extra={<Space>
|
|
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
|
|
&& <InputInstanceWithAll
|
|
value={instanceId}
|
|
onChange={value => tableState.commitFilter(
|
|
"instance", value === null ? null : [String(value)]
|
|
)}
|
|
fieldDefinition={{ optional: true } as any}
|
|
disabled={false}
|
|
/>}
|
|
{account.hasPermission("core.user.create") && <CreateUserButton />}
|
|
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
|
|
&& <BulkUserActionButton />}
|
|
</Space>}
|
|
/>
|
|
<UsersTable instanceId={instanceId ?? undefined}/>
|
|
<PluginExtra component="UsersPage" />
|
|
</PageLayout>;
|
|
}
|