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.load_scenario") && }
+ {account.hasPermission("core.instance.export_data") && {
@@ -63,8 +68,8 @@ export default function InstanceViewPage(props) {
}}
>
Export data
-
- }
+ {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
-
+
-
+
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 <>
+ { setVisible(true); }}
+ >Create
+ { 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 =>
- {
@@ -78,8 +80,8 @@ export default function SavesList(props) {
() => { setStarting(false); }
);
}}
- >Load save
- Load save }
+ {account.hasPermission("core.instance.save.download") && {
libLink.messages.downloadSave.send(
@@ -92,8 +94,8 @@ export default function SavesList(props) {
notifyErrorHandler("Error downloading save")
);
}}
- >Download
- Download}
+ {account.hasPermission("core.instance.save.delete") &&
Delete
-
+ }
,
}}
/>;
@@ -158,15 +160,19 @@ export default function SavesList(props) {
};
return
-
-
+
+ {account.hasPermission("core.instance.save.upload") &&
Upload save
-
-
- />
-
- {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()) },
+ ]}
+ >
+ 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 => {r["name"]} )}
+
+ ;
+
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()) },
- ]}
- >
- 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 => {r["name"]} )}
-
-
+ {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") &&
Apply
-
+ }
{
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
-
+ }
>
{user["is_banned"] ? "Pardon User" : "Ban User"}
-
+ }
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 <>
+ { setVisible(true); }}
+ >Create
+ { 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.setState({
- showEditModal: true,
- inserting: true,
- })}>
- Add
- }
-
- {this.state.inserting ? "Add" : "Edit"}
- }
- >
- {this.state.showEditModal &&
- {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
- ) || }
- )}
-
-
- {this.state.inserting ? "Save" : "Save"}
-
-
- }
-
-
- >;
- }
-
- 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);
}