mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
Merge pull request #413 from Hornwitser/hide-unauthorized-controls
Hide unauthorized controls
This commit is contained in:
25 files changed
+602
-506
No files matched your search
@@ -927,6 +927,8 @@ class Control extends libLink.Link {
|
||||
this.keepOpen = false;
|
||||
}
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler() { }
|
||||
|
||||
async instanceUpdateEventHandler() { }
|
||||
|
||||
@@ -1085,6 +1085,30 @@ messages.debugWsMessage = new Event({
|
||||
},
|
||||
});
|
||||
|
||||
messages.accountUpdate = new Event({
|
||||
type: "account_update",
|
||||
links: ["master-control"],
|
||||
eventRequired: [],
|
||||
eventProperties: {
|
||||
"roles": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
required: ["name", "id", "permissions"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"permissions": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.logMessage = new Event({
|
||||
type: "log_message",
|
||||
links: ["slave-master", "master-control"],
|
||||
|
||||
+18
-1
@@ -104,10 +104,27 @@ const serverHandshake = ajv.compile({
|
||||
"session_timeout": { type: "number" },
|
||||
"heartbeat_interval": { type: "number" },
|
||||
"account": {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name"],
|
||||
required: ["name", "roles"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"roles": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
required: ["name", "id", "permissions"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"permissions": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -382,6 +382,11 @@ definePermission({
|
||||
title: "List saves on instance",
|
||||
description: "List the saves currently on the instance.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list_subscribe",
|
||||
title: "Subscribe to save list updatess",
|
||||
description: "Subscribe to be notifed on updates to the list of saves of instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.create",
|
||||
title: "Create new instance save",
|
||||
@@ -402,11 +407,6 @@ definePermission({
|
||||
title: "Download save from instance",
|
||||
description: "Download savegames from instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list_subscribe",
|
||||
title: "Subscribe to save list updatess",
|
||||
description: "Subscribe to be notifed on updates to the list of saves of instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.export_data",
|
||||
title: "Export locale and icons",
|
||||
|
||||
@@ -513,6 +513,7 @@ class ControlConnection extends BaseConnection {
|
||||
role.name = name;
|
||||
role.description = description;
|
||||
role.permissions = new Set(permissions);
|
||||
this._master.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async grantDefaultRolePermissionsRequestHandler(message) {
|
||||
@@ -522,6 +523,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
this._master.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async deleteRoleRequestHandler(message) {
|
||||
@@ -534,6 +536,7 @@ class ControlConnection extends BaseConnection {
|
||||
this._master.userManager.roles.delete(id);
|
||||
for (let user of this._master.userManager.users.values()) {
|
||||
user.roles.delete(role);
|
||||
this._master.userPermissionsUpdated(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +576,7 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
user.roles = resolvedRoles;
|
||||
this._master.userPermissionsUpdated(user);
|
||||
}
|
||||
|
||||
async setUserAdminRequestHandler(message) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const path = require("path");
|
||||
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
const libPrometheus = require("@clusterio/lib/prometheus");
|
||||
@@ -410,6 +411,44 @@ class Master {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected control clients under the given user that the
|
||||
* permissions for this user may have changed.
|
||||
* @param {module:lib/users.User} user - User permisions updated for.
|
||||
*/
|
||||
userPermissionsUpdated(user) {
|
||||
for (let controlConnection of this.wsServer.controlConnections) {
|
||||
if (controlConnection.user === user) {
|
||||
libLink.messages.accountUpdate.send(controlConnection, {
|
||||
"roles": [...user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected control clients with the given role that the
|
||||
* permissions may have changed.
|
||||
* @param {module:lib/users.Role} role - Role permisions updated for.
|
||||
*/
|
||||
rolePermissionsUpdated(role) {
|
||||
for (let controlConnection of this.wsServer.controlConnections) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
libLink.messages.accountUpdate.send(controlConnection, {
|
||||
"roles": [...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
|
||||
|
||||
@@ -298,6 +298,11 @@ ${err.stack}`
|
||||
this.controlConnections.push(connection);
|
||||
additionalReadyData["account"] = {
|
||||
name: user.name,
|
||||
roles: [...user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Typography } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
@@ -49,6 +50,7 @@ function formatLog(info, key) {
|
||||
}
|
||||
|
||||
export default function InstanceConsole(props) {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let anchor = useRef();
|
||||
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
||||
@@ -59,19 +61,23 @@ export default function InstanceConsole(props) {
|
||||
let parent = anchor.current.parentElement;
|
||||
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
|
||||
|
||||
libLink.messages.queryLog.send(control, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [props.id],
|
||||
max_level: null,
|
||||
limit: 400,
|
||||
order: "desc",
|
||||
}).then(result => {
|
||||
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
|
||||
}).catch(err => {
|
||||
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
if (account.hasPermission("core.log.query")) {
|
||||
libLink.messages.queryLog.send(control, {
|
||||
all: false,
|
||||
master: false,
|
||||
slave_ids: [],
|
||||
instance_ids: [props.id],
|
||||
max_level: null,
|
||||
limit: 400,
|
||||
order: "desc",
|
||||
}).then(result => {
|
||||
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
|
||||
}).catch(err => {
|
||||
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
||||
});
|
||||
} else {
|
||||
setPastLines([]);
|
||||
}
|
||||
|
||||
function logHandler(info) {
|
||||
setLines(currentLines => currentLines.concat(
|
||||
|
||||
@@ -5,6 +5,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import InstanceConfigTree from "./InstanceConfigTree";
|
||||
@@ -27,6 +28,7 @@ export default function InstanceViewPage(props) {
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [instance] = useInstance(instanceId);
|
||||
let [slave] = useSlave(Number(instance["assigned_slave"]));
|
||||
@@ -46,9 +48,12 @@ export default function InstanceViewPage(props) {
|
||||
}
|
||||
|
||||
let instanceButtons = <Space>
|
||||
<StartStopInstanceButton instance={instance} />
|
||||
<LoadScenarioModal instance={instance} />
|
||||
<Button
|
||||
{
|
||||
account.hasAnyPermission("core.instance.start", "core.instance.stop")
|
||||
&& <StartStopInstanceButton instance={instance} />
|
||||
}
|
||||
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
|
||||
{account.hasPermission("core.instance.export_data") && <Button
|
||||
loading={exportingData}
|
||||
disabled={instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
@@ -63,8 +68,8 @@ export default function InstanceViewPage(props) {
|
||||
}}
|
||||
>
|
||||
Export data
|
||||
</Button>
|
||||
<Popconfirm
|
||||
</Button>}
|
||||
{account.hasPermission("core.instance.delete") && <Popconfirm
|
||||
title="Permanently delete instance and server saves?"
|
||||
okText="Delete"
|
||||
placement="bottomRight"
|
||||
@@ -83,7 +88,7 @@ export default function InstanceViewPage(props) {
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Popconfirm>}
|
||||
</Space>;
|
||||
|
||||
let assigned = instance["assigned_slave"] !== null;
|
||||
@@ -99,7 +104,7 @@ export default function InstanceViewPage(props) {
|
||||
? <em>Unassigned</em>
|
||||
: slave["name"] || instance["assigned_slave"]
|
||||
}
|
||||
<AssignInstanceModal
|
||||
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
|
||||
id={instanceId}
|
||||
slaveId={instance["assigned_slave"]}
|
||||
buttonProps={{
|
||||
@@ -109,16 +114,25 @@ export default function InstanceViewPage(props) {
|
||||
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
|
||||
}}
|
||||
buttonContent={assigned ? "Reassign" : "Assign"}
|
||||
/>
|
||||
/>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<SavesList instance={instance} />
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<InstanceConsole id={instanceId} />
|
||||
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
}
|
||||
{
|
||||
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
|
||||
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
}
|
||||
{account.hasPermission("core.log.follow") && <InstanceConsole id={instanceId} />}
|
||||
{
|
||||
account.hasPermission("core.instance.send_rcon")
|
||||
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
}
|
||||
|
||||
<InstanceConfigTree id={instanceId} />
|
||||
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
|
||||
import { libConfig, libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import { useInstanceList } from "../model/instance";
|
||||
@@ -59,6 +60,7 @@ function CreateInstanceButton(props) {
|
||||
|
||||
export default function InstancesPage() {
|
||||
let history = useHistory();
|
||||
let account = useAccount();
|
||||
let [slaveList] = useSlaveList();
|
||||
let [instanceList] = useInstanceList();
|
||||
|
||||
@@ -66,7 +68,7 @@ export default function InstancesPage() {
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Instances"
|
||||
extra=<CreateInstanceButton />
|
||||
extra={account.hasPermission("core.instance.create") && <CreateInstanceButton />}
|
||||
/>
|
||||
|
||||
<Table
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import MasterConfigTree from "./MasterConfigTree";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
|
||||
export default function SlavesPage() {
|
||||
let account = useAccount();
|
||||
|
||||
return <PageLayout nav={[{ name: "Master" }]}>
|
||||
<h2>Master</h2>
|
||||
<MasterConfigTree />
|
||||
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
|
||||
</PageLayout>;
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
|
||||
import { Button, Checkbox, Form, Input, PageHeader, Popconfirm, Spin } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import { libLink, libUsers } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
@@ -42,6 +43,7 @@ export default function RoleViewPage() {
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [role, updateRole] = useRole(roleId);
|
||||
let [edited, setEdited] = useState(false);
|
||||
@@ -50,14 +52,20 @@ export default function RoleViewPage() {
|
||||
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
|
||||
if (role.loading) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>{roleId}</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={roleId}
|
||||
/>
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (role.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>Role not found</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Role not found"
|
||||
/>
|
||||
<p>Role with id {roleId} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -72,6 +80,7 @@ export default function RoleViewPage() {
|
||||
},
|
||||
};
|
||||
|
||||
let canUpdate = account.hasPermission("core.role.update");
|
||||
return <PageLayout nav={nav}>
|
||||
<Form
|
||||
initialValues={initialValues}
|
||||
@@ -93,40 +102,35 @@ export default function RoleViewPage() {
|
||||
}).catch(notifyErrorHandler("Error applying changes"));
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<Col flex="auto">
|
||||
<h2>{role["name"]}</h2>
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
<Space>
|
||||
<Button type={edited ? "primary" : "default"} htmlType="submit">
|
||||
Apply
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={role["name"]}
|
||||
extra={<>
|
||||
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
|
||||
{account.hasPermission("core.role.delete") && <Popconfirm
|
||||
title="Delete role?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteRole.send(
|
||||
control, { id: roleId }
|
||||
).then(() => {
|
||||
history.push("/roles");
|
||||
}).catch(notifyErrorHandler("Error deleting role"));
|
||||
}}
|
||||
>
|
||||
<Button danger >
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="Delete role?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteRole.send(
|
||||
control, { id: roleId }
|
||||
).then(() => {
|
||||
history.push("/roles");
|
||||
}).catch(notifyErrorHandler("Error deleting role"));
|
||||
}}
|
||||
>
|
||||
<Button danger >
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Popconfirm>}
|
||||
</>}
|
||||
/>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input/>
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input/>
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
<h3>Permissions</h3>
|
||||
{[...libUsers.permissions.values()].map(({name, title, description}) => (
|
||||
@@ -141,7 +145,7 @@ export default function RoleViewPage() {
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox/>
|
||||
<Checkbox disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
|
||||
@@ -1,50 +1,100 @@
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import DataTable from "./data-table";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
|
||||
export default function UsersPage() {
|
||||
function CreateRoleButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
|
||||
async function createRole() {
|
||||
let values = form.getFieldsValue();
|
||||
if (!values.roleName) {
|
||||
form.setFields([{ name: "roleName", errors: ["Name is required"] }]);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await libLink.messages.createRole.send(control, {
|
||||
name: values.roleName,
|
||||
description: values.description || "",
|
||||
permissions: [],
|
||||
});
|
||||
setVisible(false);
|
||||
history.push(`/roles/${result.id}/view`);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setVisible(true); }}
|
||||
>Create</Button>
|
||||
<Modal
|
||||
title="Create Role"
|
||||
okText="Create"
|
||||
visible={visible}
|
||||
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
|
||||
onCancel={() => { setVisible(false); }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="roleName" label="Name">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
|
||||
async function listRoles() {
|
||||
let result = await libLink.messages.listRoles.send(control);
|
||||
return result["list"].map(item => ({
|
||||
key: item["id"],
|
||||
"Name": item["name"],
|
||||
"Description": item["description"],
|
||||
}));
|
||||
}
|
||||
let [roles, setRoles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
libLink.messages.listRoles.send(control).then(result => {
|
||||
setRoles(result["list"]);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, []);
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
<h2>Roles</h2>
|
||||
<DataTable
|
||||
DataFunction={listRoles}
|
||||
AddRecord={{
|
||||
fields: [
|
||||
{ dataIndex: "name", title: "Name" },
|
||||
{ dataIndex: "description", title: "Description" },
|
||||
],
|
||||
insert: async args => {
|
||||
await libLink.messages.createRole.send(control, {
|
||||
name: args.name,
|
||||
description: args.description || "",
|
||||
permissions: [],
|
||||
});
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Roles"
|
||||
extra={account.hasPermission("core.role.create") && <CreateRoleButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
},
|
||||
}}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/roles/${record.key}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
},
|
||||
]}
|
||||
dataSource={roles}
|
||||
pagination={false}
|
||||
rowKey={role => role["id"]}
|
||||
onRow={(role, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/roles/${role["id"]}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import CreateSaveModal from "./CreateSaveModal";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
@@ -24,6 +25,7 @@ function formatBytes(bytes) {
|
||||
}
|
||||
|
||||
export default function SavesList(props) {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let saves = useSaves(props.instance.id);
|
||||
let [starting, setStarting] = useState(false);
|
||||
@@ -65,7 +67,7 @@ export default function SavesList(props) {
|
||||
columnWidth: 33,
|
||||
expandRowByClick: true,
|
||||
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
|
||||
<Button
|
||||
{account.hasPermission("core.instance.start") && <Button
|
||||
loading={starting}
|
||||
disabled={props.instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
@@ -78,8 +80,8 @@ export default function SavesList(props) {
|
||||
() => { setStarting(false); }
|
||||
);
|
||||
}}
|
||||
>Load save</Button>
|
||||
<Button
|
||||
>Load save</Button>}
|
||||
{account.hasPermission("core.instance.save.download") && <Button
|
||||
disabled={slaveOffline}
|
||||
onClick={() => {
|
||||
libLink.messages.downloadSave.send(
|
||||
@@ -92,8 +94,8 @@ export default function SavesList(props) {
|
||||
notifyErrorHandler("Error downloading save")
|
||||
);
|
||||
}}
|
||||
>Download</Button>
|
||||
<Popconfirm
|
||||
>Download</Button>}
|
||||
{account.hasPermission("core.instance.save.delete") && <Popconfirm
|
||||
title="Permanently delete save?"
|
||||
okText="Delete"
|
||||
placement="top"
|
||||
@@ -105,7 +107,7 @@ export default function SavesList(props) {
|
||||
}}
|
||||
>
|
||||
<Button danger disabled={slaveOffline}>Delete</Button>
|
||||
</Popconfirm>
|
||||
</Popconfirm>}
|
||||
</Space>,
|
||||
}}
|
||||
/>;
|
||||
@@ -158,15 +160,19 @@ export default function SavesList(props) {
|
||||
};
|
||||
|
||||
return <div>
|
||||
<SectionHeader title="Saves" extra=<Space>
|
||||
<Upload {...uploadProps} >
|
||||
<SectionHeader title="Saves" extra={<Space>
|
||||
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
|
||||
<Button disabled={slaveOffline}>Upload save</Button>
|
||||
</Upload>
|
||||
<CreateSaveModal instance={props.instance} />
|
||||
</Space> />
|
||||
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
|
||||
{saveTable}
|
||||
</Upload.Dragger>
|
||||
</Upload>}
|
||||
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
|
||||
</Space>} />
|
||||
{
|
||||
account.hasPermission("core.instance.save.upload")
|
||||
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
|
||||
{saveTable}
|
||||
</Upload.Dragger>
|
||||
: saveTable
|
||||
}
|
||||
{uploadingFiles.length ? <List>
|
||||
{uploadingFiles.map(file => <List.Item key={file.uid}>
|
||||
{file.name}
|
||||
|
||||
@@ -27,9 +27,9 @@ export default function SiteLayout(props) {
|
||||
}
|
||||
|
||||
let accountMenu = <Menu>
|
||||
<Menu.ItemGroup title={account.name}/>
|
||||
<Menu.Item key="1" onClick={() => { history.push(`/users/${account.name}/view`); }}>{account.name}</Menu.Item>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
|
||||
<Menu.Item key="2" danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
|
||||
</Menu>;
|
||||
|
||||
let combinedPages = [...pages];
|
||||
@@ -69,9 +69,12 @@ export default function SiteLayout(props) {
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
onClick={({ key }) => history.push(key)}
|
||||
>
|
||||
{combinedPages.map(({sidebarName, path}) => (
|
||||
sidebarName ? <Menu.Item key={path}>{sidebarName}</Menu.Item> : null)
|
||||
)}
|
||||
{combinedPages.map(({sidebarName, permission, path}) => {
|
||||
if (!sidebarName || permission && !account.hasPermission(permission)) {
|
||||
return null;
|
||||
}
|
||||
return <Menu.Item key={path}>{sidebarName}</Menu.Item>;
|
||||
})}
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout className="site-layout-content-container">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import { useSlaveList } from "../model/slave";
|
||||
@@ -71,14 +72,14 @@ function GenerateSlaveTokenButton(props) {
|
||||
|
||||
|
||||
export default function SlavesPage() {
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [slaveList] = useSlaveList();
|
||||
|
||||
return <PageLayout nav={[{ name: "Slaves" }]}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Slaves"
|
||||
extra=<GenerateSlaveTokenButton />
|
||||
extra={account.hasPermission("core.slave.generate_token") && <GenerateSlaveTokenButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
|
||||
import { Button, Col, Form, Input, PageHeader, Popconfirm, Popover, Row, Tag, Select, Space, Spin, Switch } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
@@ -39,6 +40,7 @@ export default function UserViewPage() {
|
||||
|
||||
let history = useHistory();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [user, updateUser] = useUser(userName);
|
||||
let [roles, setRoles] = useState(null);
|
||||
@@ -59,25 +61,55 @@ export default function UserViewPage() {
|
||||
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
|
||||
if (user.loading) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>{userName}</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={userName}
|
||||
/>
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (user.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>User not found</h2>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="User not found"
|
||||
/>
|
||||
<p>User with name {userName} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
let roleSelector = <Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
initialValue={user["roles"]}
|
||||
rules={[
|
||||
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
onChange={() => setRolesDirty(true)}
|
||||
loading={!roles}
|
||||
mode="multiple"
|
||||
showArrow={true}
|
||||
filterOption={(inputValue, option) => {
|
||||
let role = roles.get(option.value);
|
||||
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
|
||||
}}
|
||||
>
|
||||
{roles && [...roles.values()].map(r => <Select.Option
|
||||
key={r["id"]}
|
||||
value={r["id"]}
|
||||
>{r["name"]}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>;
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Row>
|
||||
<Col flex="auto">
|
||||
<h2>{userName}</h2>
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
<Popconfirm
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={userName}
|
||||
extra={
|
||||
account.hasPermission("core.user.delete") && <Popconfirm
|
||||
title={<>
|
||||
Delete user account and all data associated with it?
|
||||
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
|
||||
@@ -97,40 +129,22 @@ export default function UserViewPage() {
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
<Form form={form}>
|
||||
<Form.Item
|
||||
label="Roles"
|
||||
>
|
||||
<Row gutter={8}>
|
||||
<Col flex="auto">
|
||||
<Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
initialValue={user["roles"]}
|
||||
rules={[
|
||||
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
onChange={() => setRolesDirty(true)}
|
||||
loading={!roles}
|
||||
mode="multiple"
|
||||
showArrow={true}
|
||||
filterOption={(inputValue, option) => {
|
||||
let role = roles.get(option.value);
|
||||
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
|
||||
}}
|
||||
>
|
||||
{roles && [...roles.values()].map(r => <Select.Option
|
||||
key={r["id"]}
|
||||
value={r["id"]}
|
||||
>{r["name"]}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{account.hasPermission("core.user.update_roles")
|
||||
? roleSelector
|
||||
: [...user["roles"]].map(id => <Tag key={id}>{
|
||||
roles ? (roles.get(id) || { name: id })["name"] : id
|
||||
}</Tag>)
|
||||
}
|
||||
</Col>
|
||||
<Col flex="0 0 auto">
|
||||
{account.hasPermission("core.user.update_roles") && <Col flex="0 0 auto">
|
||||
<Button
|
||||
type={"primary"}
|
||||
disabled={!rolesDirty}
|
||||
@@ -152,12 +166,13 @@ export default function UserViewPage() {
|
||||
});
|
||||
}}
|
||||
>Apply</Button>
|
||||
</Col>
|
||||
</Col>}
|
||||
</Row>
|
||||
</Form.Item>
|
||||
<Form.Item label="In-game Admin">
|
||||
<Switch
|
||||
checked={user["is_admin"]}
|
||||
disabled={!account.hasPermission("core.user.set_admin")}
|
||||
onClick={() => {
|
||||
libLink.messages.setUserAdmin.send(control, {
|
||||
name: userName,
|
||||
@@ -172,6 +187,7 @@ export default function UserViewPage() {
|
||||
<Form.Item label="Whitelisted">
|
||||
<Switch
|
||||
checked={user["is_whitelisted"]}
|
||||
disabled={!account.hasPermission("core.user.set_whitelisted")}
|
||||
onClick={() => {
|
||||
libLink.messages.setUserWhitelisted.send(control, {
|
||||
name: userName,
|
||||
@@ -186,7 +202,7 @@ export default function UserViewPage() {
|
||||
<Form.Item label="Banned">
|
||||
<Space>
|
||||
{user["is_banned"] ? "Yes" : "No"}
|
||||
<Popover
|
||||
{account.hasPermission("core.user.set_banned") && <Popover
|
||||
title="Ban user"
|
||||
visible={banUserPopover}
|
||||
trigger="click"
|
||||
@@ -195,7 +211,7 @@ export default function UserViewPage() {
|
||||
setBanUserPopover(!banUserPopover);
|
||||
}
|
||||
}}
|
||||
content=<Form.Item label="Reason">
|
||||
content={<Form.Item label="Reason">
|
||||
<Row gutter={8}>
|
||||
<Col flex="auto">
|
||||
<Form.Item noStyle name="reason">
|
||||
@@ -220,7 +236,7 @@ export default function UserViewPage() {
|
||||
>Ban</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
</Form.Item>}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -238,7 +254,7 @@ export default function UserViewPage() {
|
||||
}
|
||||
}}
|
||||
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
|
||||
</Popover>
|
||||
</Popover>}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,68 +1,118 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Spin, Tag } from "antd";
|
||||
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
|
||||
|
||||
import { libLink } from "@clusterio/lib";
|
||||
|
||||
import DataTable from "./data-table";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import PageLayout from "./PageLayout";
|
||||
|
||||
|
||||
function CreateUserButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [visible, setVisible] = 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 libLink.messages.createUser.send(control, { name: values.userName });
|
||||
setVisible(false);
|
||||
history.push(`/users/${values.userName}/view`);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setVisible(true); }}
|
||||
>Create</Button>
|
||||
<Modal
|
||||
title="Create User"
|
||||
okText="Create"
|
||||
visible={visible}
|
||||
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
||||
onCancel={() => { setVisible(false); }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="userName" label="Name">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
|
||||
let [roles, setRoles] = useState(null);
|
||||
let [users, setUsers] = useState([]);
|
||||
let [roles, setRoles] = useState(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
libLink.messages.listUsers.send(control).then(result => {
|
||||
setUsers(result["list"]);
|
||||
}).catch(notifyErrorHandler("Error fetching user list"));
|
||||
libLink.messages.listRoles.send(control).then(result => {
|
||||
setRoles(new Map(result["list"].map(role => [role["id"], role])));
|
||||
}).catch(() => {
|
||||
setRoles(new Map());
|
||||
// ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function listUsers() {
|
||||
let result = await libLink.messages.listUsers.send(control);
|
||||
return result["list"].map(item => ({
|
||||
key: item["name"],
|
||||
"Name": item["name"],
|
||||
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
|
||||
"Admin": item["is_admin"] && "Yes",
|
||||
"Whitelisted": item["is_whitelisted"] && "Yes",
|
||||
"Banned": item["is_banned"] && "Yes",
|
||||
}));
|
||||
}
|
||||
|
||||
// DataTable does not make it possible to update the rendering function
|
||||
if (roles === null) {
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<h2>Users</h2>
|
||||
<Spin/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<h2>Users</h2>
|
||||
<DataTable
|
||||
DataFunction={listUsers}
|
||||
AddRecord={{
|
||||
fields: [{
|
||||
dataIndex: "name",
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Users"
|
||||
extra={account.hasPermission("core.user.create") && <CreateUserButton />}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
}],
|
||||
insert: async args => {
|
||||
await libLink.messages.createUser.send(control, { name: args.name });
|
||||
dataIndex: "name",
|
||||
},
|
||||
}}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/users/${record.key}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
|
||||
},
|
||||
{
|
||||
title: "Admin",
|
||||
key: "admin",
|
||||
render: user => user["is_admin"] && "yes",
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "Whitelisted",
|
||||
key: "whitelisted",
|
||||
render: user => user["is_whitelisted"] && "yes",
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "Banned",
|
||||
key: "banned",
|
||||
render: user => user["is_banned"] && "yes",
|
||||
responsive: ["lg"],
|
||||
},
|
||||
]}
|
||||
dataSource={users}
|
||||
pagination={false}
|
||||
rowKey={user => user["name"]}
|
||||
onRow={(user, rowIndex) => ({
|
||||
onClick: event => {
|
||||
history.push(`/users/${user["name"]}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import notify from "../util/notify";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
/**
|
||||
* Display data in table. Allows for editing data by clicking rows in the table.
|
||||
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
|
||||
*
|
||||
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
|
||||
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
|
||||
* Editable - Boolean - Allow editing
|
||||
* AddRecord - Boolean - Allow adding new records
|
||||
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
|
||||
* RecordIdField - String -
|
||||
* By default we assume the column "Id" is a unique identifier for a record. If
|
||||
* that isn't the case, supply the column name here
|
||||
*/
|
||||
class DataTable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!this.props.DataFunction) {
|
||||
throw new Error("DataFunction not defined");
|
||||
}
|
||||
this.state = {
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
current: 1,
|
||||
},
|
||||
filters: this.props.Filters,
|
||||
sorter: {},
|
||||
tableData: [],
|
||||
showEditModal: false,
|
||||
record: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async getData(pagination, filters, sorter) {
|
||||
// pagination = {
|
||||
// pageSize: 10, // items per page
|
||||
// current: 1, // page number
|
||||
// total: 123, // Total amount of items
|
||||
// }
|
||||
// filters = {
|
||||
// ReportId: 123
|
||||
// }
|
||||
// sorter = {}
|
||||
pagination = pagination || this.state.pagination;
|
||||
filters = filters || this.state.filters;
|
||||
sorter = sorter || this.state.sorter;
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
let data = await this.props.DataFunction();
|
||||
data = data || {};
|
||||
this.setState({
|
||||
// eslint-disable-next-line new-cap
|
||||
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
|
||||
pagination: { ...pagination, ...data.pagination },
|
||||
sorter,
|
||||
filters,
|
||||
});
|
||||
}
|
||||
|
||||
showEditModal(record, index) {
|
||||
if (!this.state.showEditModal) {
|
||||
this.setState({
|
||||
record: record,
|
||||
showEditModal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideEditModal() {
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
}
|
||||
|
||||
async onEditFinish(values) {
|
||||
if (this.state.inserting) { // Create new record
|
||||
await this.props.AddRecord.insert(values);
|
||||
} else { // Edit existing record, capable of doing sparse updates
|
||||
await this.props.Editable.insert(values);
|
||||
}
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async deleteRecord(record) {
|
||||
delete record.key;
|
||||
// Delete "null" values as they seem to cause deletes to fail silently when
|
||||
// the value is null and field type is float
|
||||
for (let key in record) {
|
||||
if (record[key] === null) { delete record[key]; }
|
||||
}
|
||||
let status = {};
|
||||
// TODO: Delete data here
|
||||
|
||||
if (status.ok) { notify("success", status.msg, "success"); }
|
||||
if (!status.ok) { notify("error", status.msg, "error"); }
|
||||
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
render() {
|
||||
let tableProps = {
|
||||
...this.props.TableProps || {},
|
||||
onRow: (record, rowIndex) => {
|
||||
// To allow for extending functionality, we take a TableProps argument
|
||||
// from props and pass it through to the Table component. Since we
|
||||
// are also using onRow internaly here, we handle the externally
|
||||
// provided events ourselves by passing along the event object. This
|
||||
// way the end user isn't affected by our event handlers.
|
||||
let externalOnRow = {
|
||||
...(
|
||||
this.props.TableProps
|
||||
&& this.props.TableProps.onRow
|
||||
&& this.props.TableProps.onRow(record, rowIndex)
|
||||
),
|
||||
};
|
||||
return {
|
||||
onClick: event => {
|
||||
if (event.target.type !== "button") {
|
||||
if (this.props.Editable) { this.showEditModal(record, rowIndex); }
|
||||
if (externalOnRow.onClick) { externalOnRow.onClick(event); }
|
||||
}
|
||||
},
|
||||
onDoubleClick: event => {
|
||||
if (externalOnRow.onDoubleClick) { externalOnRow.onDoubleClick(event); }
|
||||
},
|
||||
onContextMenu: event => {
|
||||
if (externalOnRow.onContextMenu) { externalOnRow.onContextMenu(event); }
|
||||
},
|
||||
onMouseEnter: event => {
|
||||
if (externalOnRow.onMouseEnter) { externalOnRow.onMouseEnter(event); }
|
||||
},
|
||||
onMouseLeave: event => {
|
||||
if (externalOnRow.onMouseLeave) { externalOnRow.onMouseLeave(event); }
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
let columns = this.props.columns || this.inferColumns(this.state.tableData);
|
||||
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) {
|
||||
columns.push({
|
||||
// Add delete button on the right
|
||||
dataIndex: "notInUse",
|
||||
title: "",
|
||||
render: (text, record) => <Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => {
|
||||
this.deleteRecord(record);
|
||||
}}
|
||||
>
|
||||
Slett permanent
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>,
|
||||
});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<style>
|
||||
{`
|
||||
.editableTable .ant-table-tbody > tr {
|
||||
cursor:pointer
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Table
|
||||
className={this.props.Editable && "editableTable"}
|
||||
{...tableProps}
|
||||
columns={columns.map(x => ({
|
||||
...x, // Add a 4th parameter to the stock render function with all of our table data
|
||||
render: x.render
|
||||
? (...originalParams) => x.render(...originalParams, this.state.tableData)
|
||||
: undefined,
|
||||
}))}
|
||||
dataSource={this.state.tableData}
|
||||
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
|
||||
onChange={this.getData.bind(this)}
|
||||
/>
|
||||
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
|
||||
showEditModal: true,
|
||||
inserting: true,
|
||||
})}>
|
||||
<PlusOutlined />Add
|
||||
</Button>}
|
||||
</Col>
|
||||
<Modal
|
||||
visible={this.state.showEditModal}
|
||||
onCancel={this.hideEditModal.bind(this)}
|
||||
footer={null}
|
||||
title={
|
||||
<h2>{this.state.inserting ? "Add" : "Edit"}</h2>
|
||||
}
|
||||
>
|
||||
{this.state.showEditModal && <Form
|
||||
labelCol={{
|
||||
span: 6,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 12,
|
||||
}}
|
||||
layout="horizontal"
|
||||
onFinish={this.onEditFinish.bind(this)}
|
||||
initialValues={this.state.record}
|
||||
>
|
||||
{(
|
||||
// Use externally supplied form input fields
|
||||
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
|
||||
// Generate input fields from external columns
|
||||
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
|
||||
// Generate input fields from autogenerated columns (only works with existing data)
|
||||
|| this.inferColumns(this.state.tableData)
|
||||
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
|
||||
{col.renderEdit && col.renderEdit(
|
||||
this.state.record[col.dataIndex],
|
||||
this.state.record,
|
||||
undefined /* Non spec compliant, is supposed to be index of record */,
|
||||
this.state.tableData
|
||||
) || <Input placeholder={this.state.record[col.dataIndex]} />}
|
||||
</Form.Item>)}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{this.state.inserting ? "Save" : "Save"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</Modal>
|
||||
</Row>
|
||||
</>;
|
||||
}
|
||||
|
||||
inferColumns(data) {
|
||||
// Look at the data and find columns and how to display them. Can be
|
||||
// overridden by props.Columns if more customization is needed
|
||||
let columns = [];
|
||||
data.forEach(row => {
|
||||
Object.keys(row).filter(x => x !== "key").forEach(key => {
|
||||
let existing = columns.find(col => col.key === key);
|
||||
if (!existing) {
|
||||
columns.push({
|
||||
key: key,
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
@@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext";
|
||||
/**
|
||||
* @typedef {Object} Account
|
||||
* @property {?string} name - Name of the currently logged in account.
|
||||
* @property {?Array<object>} roles - Roles of the corrently logged in account.
|
||||
* @property {function(string)} hasPermission -
|
||||
* Check if the currently logged in account has the given permission.
|
||||
* @property {function()} logout - Logs out of the web interface.
|
||||
*/
|
||||
|
||||
export function useAccount() {
|
||||
let control = useContext(ControlContext);
|
||||
let [name, setName] = useState(control.accountName);
|
||||
let [roles, setRoles] = useState(control.accountRoles);
|
||||
|
||||
useEffect(() => {
|
||||
function onConnect(data) {
|
||||
setName(data.account.name);
|
||||
function onAccountUpdate(account) {
|
||||
setName(account.name);
|
||||
setRoles(account.roles);
|
||||
}
|
||||
function onClose() {
|
||||
setName(null);
|
||||
}
|
||||
control.connector.on("connect", onConnect);
|
||||
control.connector.on("close", onClose);
|
||||
control.onAccountUpdate(onAccountUpdate);
|
||||
return () => {
|
||||
control.connector.off("connect", onConnect);
|
||||
control.connector.off("close", onClose);
|
||||
control.offAccountUpdate(onAccountUpdate);
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
function hasPermission(permission) {
|
||||
for (let role of roles) {
|
||||
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
roles,
|
||||
hasPermission(permission) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
return hasPermission(permission);
|
||||
},
|
||||
hasAnyPermission(...permissions) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
for (let permission of permissions) {
|
||||
if (hasPermission(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasAllPermission(...permissions) {
|
||||
if (!roles) {
|
||||
return null;
|
||||
}
|
||||
for (let permission of permissions) {
|
||||
if (!hasPermission(permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
logOut() {
|
||||
control.loggingOut = true;
|
||||
control.shutdown(notifyErrorHandler("Error logging out"));
|
||||
|
||||
@@ -13,16 +13,60 @@ import PluginViewPage from "./components/PluginViewPage";
|
||||
|
||||
|
||||
export const pages = [
|
||||
{ path: "/master", sidebarName: "Master", content: <MasterPage /> },
|
||||
{ path: "/slaves", sidebarName: "Slaves", content: <SlavesPage /> },
|
||||
{ path: "/instances", sidebarName: "Instances", content: <InstancesPage />},
|
||||
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
|
||||
{ path: "/users", sidebarName: "Users", content: <UsersPage /> },
|
||||
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
|
||||
{ path: "/roles", sidebarName: "Roles", content: <RolesPage /> },
|
||||
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
|
||||
{ path: "/plugins", sidebarName: "Plugins", content: <PluginsPage /> },
|
||||
{ path: "/plugins/:name/view", sidebarPath: "/plugins", content: <PluginViewPage /> },
|
||||
{
|
||||
path: "/master",
|
||||
sidebarName: "Master",
|
||||
content: <MasterPage />,
|
||||
},
|
||||
{
|
||||
path: "/slaves",
|
||||
sidebarName: "Slaves",
|
||||
permission: "core.slave.list",
|
||||
content: <SlavesPage />,
|
||||
},
|
||||
{
|
||||
path: "/instances",
|
||||
sidebarName: "Instances",
|
||||
permission: "core.instance.list",
|
||||
content: <InstancesPage />,
|
||||
},
|
||||
{
|
||||
path: "/instances/:id/view",
|
||||
sidebarPath: "/instances",
|
||||
content: <InstanceViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
sidebarName: "Users",
|
||||
permission: "core.user.list",
|
||||
content: <UsersPage />,
|
||||
},
|
||||
{
|
||||
path: "/users/:name/view",
|
||||
sidebarPath: "/users",
|
||||
content: <UserViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/roles",
|
||||
sidebarName: "Roles",
|
||||
permission: "core.user.list",
|
||||
content: <RolesPage />,
|
||||
},
|
||||
{
|
||||
path: "/roles/:id/view",
|
||||
sidebarPath: "/roles",
|
||||
content: <RoleViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/plugins",
|
||||
sidebarName: "Plugins",
|
||||
content: <PluginsPage />,
|
||||
},
|
||||
{
|
||||
path: "/plugins/:name/view",
|
||||
sidebarPath: "/plugins",
|
||||
content: <PluginViewPage />,
|
||||
},
|
||||
];
|
||||
|
||||
export const sidebarPages = [
|
||||
|
||||
@@ -60,6 +60,13 @@ export class Control extends libLink.Link {
|
||||
*/
|
||||
this.accountName = null;
|
||||
|
||||
/**
|
||||
* Roles of the account this control link is connected as.
|
||||
* @type {?Array<object>}
|
||||
*/
|
||||
this.accountRoles = null;
|
||||
|
||||
this.accountUpdateHandlers = [];
|
||||
this.slaveUpdateHandlers = new Map();
|
||||
this.instanceUpdateHandlers = new Map();
|
||||
this.saveListUpdateHandlers = new Map();
|
||||
@@ -67,6 +74,8 @@ export class Control extends libLink.Link {
|
||||
|
||||
this.connector.on("connect", data => {
|
||||
this.accountName = data.account.name;
|
||||
this.accountRoles = data.account.roles;
|
||||
this.emitAccountUpdate();
|
||||
this.updateSlaveSubscriptions().catch(err => logger.error(
|
||||
`Unexpected error updating slave subscriptions:\n${err.stack}`
|
||||
));
|
||||
@@ -83,6 +92,8 @@ export class Control extends libLink.Link {
|
||||
|
||||
this.connector.on("close", () => {
|
||||
this.accountName = null;
|
||||
this.accountRoles = null;
|
||||
this.emitAccountUpdate();
|
||||
});
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"]) {
|
||||
@@ -94,6 +105,32 @@ export class Control extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async accountUpdateEventHandler(message) {
|
||||
this.accountRoles = message.data.roles;
|
||||
this.emitAccountUpdate();
|
||||
}
|
||||
|
||||
emitAccountUpdate() {
|
||||
for (let handler of this.accountUpdateHandlers) {
|
||||
handler({
|
||||
name: this.accountName,
|
||||
roles: this.accountRoles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onAccountUpdate(handler) {
|
||||
this.accountUpdateHandlers.push(handler);
|
||||
}
|
||||
|
||||
offAccountUpdate(handler) {
|
||||
let index = this.accountUpdateHandlers.lastIndexOf(handler);
|
||||
if (index === -1) {
|
||||
throw new Error("Given handler is not registered for account update");
|
||||
}
|
||||
this.accountUpdateHandlers.splice(index, 1);
|
||||
}
|
||||
|
||||
async slaveUpdateEventHandler(message) {
|
||||
let handlers = [].concat(
|
||||
this.slaveUpdateHandlers.get(null) || [],
|
||||
|
||||
@@ -31,7 +31,12 @@ function InventoryPage() {
|
||||
export class WebPlugin extends libPlugin.BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{ path: "/inventory", sidebarName: "Inventory sync", content: <InventoryPage /> },
|
||||
{
|
||||
path: "/inventory",
|
||||
sidebarName: "Inventory sync",
|
||||
permission: "inventory_sync.inventory.view",
|
||||
content: <InventoryPage />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,12 @@ function StoragePage() {
|
||||
export class WebPlugin extends libPlugin.BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{ path: "/storage", sidebarName: "Storage", content: <StoragePage/> },
|
||||
{
|
||||
path: "/storage",
|
||||
sidebarName: "Storage",
|
||||
permission: "subspace_storage.storage.view",
|
||||
content: <StoragePage/>,
|
||||
},
|
||||
];
|
||||
|
||||
this.storage = new Map();
|
||||
|
||||
@@ -49,6 +49,8 @@ class TestControl extends libLink.Link {
|
||||
|
||||
async debugWsMessageEventHandler() { }
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler(message) {
|
||||
this.slaveUpdates.push(message.data);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user