From 18e9fc8e7c41bb27f92f9b4f5a767d375d1078d2 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Sep 2021 00:49:59 +0200 Subject: [PATCH 1/4] Provide roles and permission for the control user Add the roles and their permissions for the user the control connection is connected as along with updates when these change. This enables the web interface to know which permissions it has available to it. --- packages/ctl/ctl.js | 2 + packages/lib/link/messages.js | 24 ++++++++++ packages/lib/schema.js | 19 +++++++- packages/master/src/ControlConnection.js | 4 ++ packages/master/src/Master.js | 39 +++++++++++++++++ packages/master/src/WsServer.js | 5 +++ packages/web_ui/src/model/account.jsx | 56 ++++++++++++++++++++---- packages/web_ui/src/util/websocket.jsx | 37 ++++++++++++++++ test/integration/index.js | 2 + 9 files changed, 178 insertions(+), 10 deletions(-) diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 6cdff8b5..ac01a274 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -927,6 +927,8 @@ class Control extends libLink.Link { this.keepOpen = false; } + async accountUpdateEventHandler() { } + async slaveUpdateEventHandler() { } async instanceUpdateEventHandler() { } diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 5cc260d2..5a13f6dd 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -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"], diff --git a/packages/lib/schema.js b/packages/lib/schema.js index 6753fd69..da43a843 100644 --- a/packages/lib/schema.js +++ b/packages/lib/schema.js @@ -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" }, + }, + }, + }, + }, }, }, }, diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 1d2efd49..69e1f5e9 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -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) { diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index f7590049..52711027 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -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")) { diff --git a/packages/master/src/WsServer.js b/packages/master/src/WsServer.js index ea0ada5f..9c383976 100644 --- a/packages/master/src/WsServer.js +++ b/packages/master/src/WsServer.js @@ -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], + })), }; } diff --git a/packages/web_ui/src/model/account.jsx b/packages/web_ui/src/model/account.jsx index e2b7631b..165a50d1 100644 --- a/packages/web_ui/src/model/account.jsx +++ b/packages/web_ui/src/model/account.jsx @@ -6,30 +6,68 @@ import ControlContext from "../components/ControlContext"; /** * @typedef {Object} Account * @property {?string} name - Name of the currently logged in account. + * @property {?Array} 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")); diff --git a/packages/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index aab12ecf..18c54f1c 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -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} + */ + 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) || [], diff --git a/test/integration/index.js b/test/integration/index.js index 5dc04010..0bc08be8 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -49,6 +49,8 @@ class TestControl extends libLink.Link { async debugWsMessageEventHandler() { } + async accountUpdateEventHandler() { } + async slaveUpdateEventHandler(message) { this.slaveUpdates.push(message.data); } From 0c74aef68e13a9a8be0d6c99acc9df86ad0a731b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Sep 2021 00:54:43 +0200 Subject: [PATCH 2/4] Hide unauthorized web interface controls Hide or disable controls in the web interface that the user for the control connection it's connected as don't have the requisite permissions to use. --- .../web_ui/src/components/InstanceConsole.jsx | 32 +- .../src/components/InstanceViewPage.jsx | 40 ++- .../web_ui/src/components/InstancesPage.jsx | 4 +- packages/web_ui/src/components/MasterPage.jsx | 5 +- .../web_ui/src/components/RoleViewPage.jsx | 72 ++--- packages/web_ui/src/components/RolesPage.jsx | 116 ++++++-- packages/web_ui/src/components/SavesList.jsx | 34 ++- packages/web_ui/src/components/SiteLayout.jsx | 9 +- packages/web_ui/src/components/SlavesPage.jsx | 5 +- .../web_ui/src/components/UserViewPage.jsx | 98 +++--- packages/web_ui/src/components/UsersPage.jsx | 132 +++++--- packages/web_ui/src/components/data-table.jsx | 281 ------------------ packages/web_ui/src/pages.jsx | 64 +++- plugins/inventory_sync/web/index.jsx | 7 +- plugins/subspace_storage/web/index.jsx | 7 +- 15 files changed, 417 insertions(+), 489 deletions(-) delete mode 100644 packages/web_ui/src/components/data-table.jsx diff --git a/packages/web_ui/src/components/InstanceConsole.jsx b/packages/web_ui/src/components/InstanceConsole.jsx index 30e8f76f..aa2d50ca 100644 --- a/packages/web_ui/src/components/InstanceConsole.jsx +++ b/packages/web_ui/src/components/InstanceConsole.jsx @@ -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([{"Loading past entries..."}
]); @@ -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([{`Error loading log: ${err.message}`}
]); - }); + 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([{`Error loading log: ${err.message}`}
]); + }); + } else { + setPastLines([]); + } function logHandler(info) { setLines(currentLines => currentLines.concat( diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index d38a300f..e9bf72c9 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -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 = - - - - } + {account.hasPermission("core.instance.delete") && - + } ; let assigned = instance["assigned_slave"] !== null; @@ -99,7 +104,7 @@ export default function InstanceViewPage(props) { ? Unassigned : slave["name"] || instance["assigned_slave"] } - + />} {instance["status"]} - - Console - - + { + account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe") + && + } + { + account.hasAnyPermission("core.log.follow", "core.instance.send_rcon") + && Console + } + {account.hasPermission("core.log.follow") && } + { + account.hasPermission("core.instance.send_rcon") + && + } - + {account.hasPermission("core.instance.get_config") && } ; } diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx index be54b6c7..e7020a05 100644 --- a/packages/web_ui/src/components/InstancesPage.jsx +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -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() { + extra={account.hasPermission("core.instance.create") && } />

Master

- + {account.hasPermission("core.master.get_config") && } ; }; diff --git a/packages/web_ui/src/components/RoleViewPage.jsx b/packages/web_ui/src/components/RoleViewPage.jsx index 900dfb07..ddd7a9f0 100644 --- a/packages/web_ui/src/components/RoleViewPage.jsx +++ b/packages/web_ui/src/components/RoleViewPage.jsx @@ -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 -

{roleId}

+
; } if (role.missing) { return -

Role not found

+

Role with id {roleId} was not found on the master server.

; } @@ -72,6 +80,7 @@ export default function RoleViewPage() { }, }; + let canUpdate = account.hasPermission("core.role.update"); return
- -
-

{role["name"]}

- - - - } + {account.hasPermission("core.role.delete") && { + libLink.messages.deleteRole.send( + control, { id: roleId } + ).then(() => { + history.push("/roles"); + }).catch(notifyErrorHandler("Error deleting role")); + }} + > + - { - libLink.messages.deleteRole.send( - control, { id: roleId } - ).then(() => { - history.push("/roles"); - }).catch(notifyErrorHandler("Error deleting role")); - }} - > - - - - - + } + } + /> - + - +

Permissions

{[...libUsers.permissions.values()].map(({name, title, description}) => ( @@ -141,7 +145,7 @@ export default function RoleViewPage() { labelAlign="left" colon={false} > - + ))} diff --git a/packages/web_ui/src/components/RolesPage.jsx b/packages/web_ui/src/components/RolesPage.jsx index e4a0aca2..388a48f1 100644 --- a/packages/web_ui/src/components/RolesPage.jsx +++ b/packages/web_ui/src/components/RolesPage.jsx @@ -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 <> + + { createRole().catch(notifyErrorHandler("Error creating role")); }} + onCancel={() => { setVisible(false); }} + destroyOnClose + > +
+ + + + + + + +
+ ; +} + +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 -

Roles

- { - await libLink.messages.createRole.send(control, { - name: args.name, - description: args.description || "", - permissions: [], - }); + } + /> +
({ - 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`); + }, + })} /> ; } diff --git a/packages/web_ui/src/components/SavesList.jsx b/packages/web_ui/src/components/SavesList.jsx index 8b61f063..72c0554a 100644 --- a/packages/web_ui/src/components/SavesList.jsx +++ b/packages/web_ui/src/components/SavesList.jsx @@ -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 => - - } + {account.hasPermission("core.instance.save.download") && - Download} + {account.hasPermission("core.instance.save.delete") && - + } , }} />; @@ -158,15 +160,19 @@ export default function SavesList(props) { }; return
- - + + {account.hasPermission("core.instance.save.upload") && - - - /> - - {saveTable} - + } + {account.hasPermission("core.instance.save.create") && } + } /> + { + account.hasPermission("core.instance.save.upload") + ? + {saveTable} + + : saveTable + } {uploadingFiles.length ? {uploadingFiles.map(file => {file.name} diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index 843d3e6b..5da64ebc 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -69,9 +69,12 @@ export default function SiteLayout(props) { style={{ height: "100%", borderRight: 0 }} onClick={({ key }) => history.push(key)} > - {combinedPages.map(({sidebarName, path}) => ( - sidebarName ? {sidebarName} : null) - )} + {combinedPages.map(({sidebarName, permission, path}) => { + if (!sidebarName || permission && !account.hasPermission(permission)) { + return null; + } + return {sidebarName}; + })} diff --git a/packages/web_ui/src/components/SlavesPage.jsx b/packages/web_ui/src/components/SlavesPage.jsx index 751480e7..15a83f3b 100644 --- a/packages/web_ui/src/components/SlavesPage.jsx +++ b/packages/web_ui/src/components/SlavesPage.jsx @@ -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 + extra={account.hasPermission("core.slave.generate_token") && } />
-

{userName}

+ ; } if (user.missing) { return -

User not found

+

User with name {userName} was not found on the master server.

; } + let roleSelector = (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, + ]} + > + + ; + return - -
-

{userName}

- - - Delete user account and all data associated with it? {(user["is_banned"] && <>
This will remove the user ban!)} @@ -97,40 +129,22 @@ export default function UserViewPage() {
- - + } + /> - (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, - ]} - > - - + {account.hasPermission("core.user.update_roles") + ? roleSelector + : [...user["roles"]].map(id => { + roles ? (roles.get(id) || { name: id })["name"] : id + }) + } - + {account.hasPermission("core.user.update_roles") && - + } { libLink.messages.setUserAdmin.send(control, { name: userName, @@ -172,6 +187,7 @@ export default function UserViewPage() { { libLink.messages.setUserWhitelisted.send(control, { name: userName, @@ -186,7 +202,7 @@ export default function UserViewPage() { {user["is_banned"] ? "Yes" : "No"} - + content={ @@ -220,7 +236,7 @@ export default function UserViewPage() { >Ban - + } > - + } diff --git a/packages/web_ui/src/components/UsersPage.jsx b/packages/web_ui/src/components/UsersPage.jsx index e70911a5..c8f98376 100644 --- a/packages/web_ui/src/components/UsersPage.jsx +++ b/packages/web_ui/src/components/UsersPage.jsx @@ -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 <> + + { createUser().catch(notifyErrorHandler("Error creating user")); }} + onCancel={() => { setVisible(false); }} + destroyOnClose + > +
+ + + + +
+ ; +} + 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 => {(roles.get(id) || { name: id })["name"]}), - "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 -

Users

- -
; - } - return -

Users

- } + /> +
{ - 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 => {(roles.get(id) || { name: id })["name"]}), + }, + { + 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`); + }, + })} /> ; } diff --git a/packages/web_ui/src/components/data-table.jsx b/packages/web_ui/src/components/data-table.jsx deleted file mode 100644 index 49c46467..00000000 --- a/packages/web_ui/src/components/data-table.jsx +++ /dev/null @@ -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: - * - * DataFunction - String, corresponds to export from util/wslink. Same as ws message name. - * columns - Array of objects, optional, same as
. 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) => { - this.deleteRecord(record); - }} - > - Slett permanent - - } - trigger="click" - > - - , - }); - } - - return <> - - - -
({ - ...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 && } - - {this.state.inserting ? "Add" : "Edit"} - } - > - {this.state.showEditModal &&
- {( - // 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 => - {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 - ) || } - )} - - - - } -
- - ; - } - - 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; diff --git a/packages/web_ui/src/pages.jsx b/packages/web_ui/src/pages.jsx index 6ffe389b..b643b0e5 100644 --- a/packages/web_ui/src/pages.jsx +++ b/packages/web_ui/src/pages.jsx @@ -13,16 +13,60 @@ import PluginViewPage from "./components/PluginViewPage"; export const pages = [ - { path: "/master", sidebarName: "Master", content: }, - { path: "/slaves", sidebarName: "Slaves", content: }, - { path: "/instances", sidebarName: "Instances", content: }, - { path: "/instances/:id/view", sidebarPath: "/instances", content: }, - { path: "/users", sidebarName: "Users", content: }, - { path: "/users/:name/view", sidebarPath: "/users", content: }, - { path: "/roles", sidebarName: "Roles", content: }, - { path: "/roles/:id/view", sidebarPath: "/roles", content: }, - { path: "/plugins", sidebarName: "Plugins", content: }, - { path: "/plugins/:name/view", sidebarPath: "/plugins", content: }, + { + path: "/master", + sidebarName: "Master", + content: , + }, + { + path: "/slaves", + sidebarName: "Slaves", + permission: "core.slave.list", + content: , + }, + { + path: "/instances", + sidebarName: "Instances", + permission: "core.instance.list", + content: , + }, + { + path: "/instances/:id/view", + sidebarPath: "/instances", + content: , + }, + { + path: "/users", + sidebarName: "Users", + permission: "core.user.list", + content: , + }, + { + path: "/users/:name/view", + sidebarPath: "/users", + content: , + }, + { + path: "/roles", + sidebarName: "Roles", + permission: "core.user.list", + content: , + }, + { + path: "/roles/:id/view", + sidebarPath: "/roles", + content: , + }, + { + path: "/plugins", + sidebarName: "Plugins", + content: , + }, + { + path: "/plugins/:name/view", + sidebarPath: "/plugins", + content: , + }, ]; export const sidebarPages = [ diff --git a/plugins/inventory_sync/web/index.jsx b/plugins/inventory_sync/web/index.jsx index 8261705f..a7fdc6ee 100644 --- a/plugins/inventory_sync/web/index.jsx +++ b/plugins/inventory_sync/web/index.jsx @@ -31,7 +31,12 @@ function InventoryPage() { export class WebPlugin extends libPlugin.BaseWebPlugin { async init() { this.pages = [ - { path: "/inventory", sidebarName: "Inventory sync", content: }, + { + path: "/inventory", + sidebarName: "Inventory sync", + permission: "inventory_sync.inventory.view", + content: , + }, ]; } diff --git a/plugins/subspace_storage/web/index.jsx b/plugins/subspace_storage/web/index.jsx index c6123713..a143dc5c 100644 --- a/plugins/subspace_storage/web/index.jsx +++ b/plugins/subspace_storage/web/index.jsx @@ -117,7 +117,12 @@ function StoragePage() { export class WebPlugin extends libPlugin.BaseWebPlugin { async init() { this.pages = [ - { path: "/storage", sidebarName: "Storage", content: }, + { + path: "/storage", + sidebarName: "Storage", + permission: "subspace_storage.storage.view", + content: , + }, ]; this.storage = new Map(); From 31e9f59d8d2d2977ec0ce18022a2590f098afac7 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Sep 2021 00:56:47 +0200 Subject: [PATCH 3/4] Move save list subscription permission Move the save list subscription permission to be after the save list permission as these two permissions are logically related to each other and both need to be granted to view the save list in the web interface. --- packages/lib/users.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lib/users.js b/packages/lib/users.js index 247666c0..4b42425b 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -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", From 8b59c893228b409a8e83e67e1e59948cc7835a19 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Sep 2021 00:57:38 +0200 Subject: [PATCH 4/4] Link account menu name to user account Take the user to the user view page for their own account when clicking on the name in the top right account menu. --- packages/web_ui/src/components/SiteLayout.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index 5da64ebc..089b605d 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -27,9 +27,9 @@ export default function SiteLayout(props) { } let accountMenu = - + { history.push(`/users/${account.name}/view`); }}>{account.name} - { account.logOut(); }}>Log out + { account.logOut(); }}>Log out ; let combinedPages = [...pages];