Merge pull request #413 from Hornwitser/hide-unauthorized-controls

Hide unauthorized controls
This commit is contained in:
Hornwitser authored and GitHub committed 2021-09-22 22:14:15 +02:00
commit bfcb57002d
25 files changed
+602 -506

No files matched your search

+2
View File
@@ -927,6 +927,8 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
+24
View File
@@ -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
View File
@@ -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" },
},
},
},
},
},
},
},
+5 -5
View File
@@ -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",
+4
View File
@@ -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) {
+39
View File
@@ -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")) {
+5
View File
@@ -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>;
};
+38 -34
View File
@@ -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>
+83 -33
View File
@@ -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>;
}
+20 -14
View File
@@ -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={[
+57 -41
View File
@@ -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>
+91 -41
View File
@@ -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;
+47 -9
View File
@@ -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"));
+54 -10
View File
@@ -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 = [
+37
View File
@@ -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) || [],
+6 -1
View File
@@ -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 />,
},
];
}
+6 -1
View File
@@ -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();
+2
View File
@@ -49,6 +49,8 @@ class TestControl extends libLink.Link {
async debugWsMessageEventHandler() { }
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler(message) {
this.slaveUpdates.push(message.data);
}