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/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", 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/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..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]; @@ -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/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/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/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/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(); 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); }