diff --git a/packages/web_ui/src/components/PluginExtra.tsx b/packages/web_ui/src/components/PluginExtra.tsx index 436ccc53..840dc2c4 100644 --- a/packages/web_ui/src/components/PluginExtra.tsx +++ b/packages/web_ui/src/components/PluginExtra.tsx @@ -8,6 +8,7 @@ import BaseWebPlugin from "../BaseWebPlugin"; type PluginExtraProps = { component: string; + search?: string; instance?: lib.InstanceDetails; user?: lib.UserDetails; host?: lib.HostDetails; diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 29c9526d..a1ba0664 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useContext, useState } from "react"; +import React, { useEffect, useContext, useState, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; +import { Button, Checkbox, Input, Popconfirm, Space, Spin, Tooltip } from "antd"; +import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -13,20 +13,165 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import { notifyErrorHandler } from "../util/notify"; + +/** Splits a permission name into parts, removing the last part */ +function getGroupName(name: string) { + const parts = name.split("."); + return parts.length <= 2 + ? parts.slice(0, 2).join(".") + : parts.slice(0, parts.length - 1).join("."); +} + +/** Converts a group name into Title Case with _ and . removed */ +function formatGroupTitle(name: string) { + return name + .replace(/\./g, " / ") + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** Check if a permission is dangerous, in the future we might have metadata for this */ +function isDangerous(permission: lib.Permission) { + return ["delete", "kill", "stop", "revoke", "update", "core.admin"] + .some(word => permission.name.includes(word)); +} + +/** A single permission item inside a group; includes a checkbox, name, and description */ +function PermissionItem({ + permission, baseline, checked, disabled, onChange, +}: { + permission: lib.Permission; + checked: boolean; + baseline: boolean; + disabled: boolean; + onChange: (value: boolean) => void; +}) { + const modified = checked !== baseline; + + return ( +
+
+ onChange(e.target.checked)}> + + {permission.title} + + {permission.grantByDefault && ( + + + + )} + + {isDangerous(permission) && ( + + + + )} + + +
+ +
+ {permission.description} +
+
+ ); +} + +/** A group of permissions; includes a title, clear and select all buttons, and the permission items */ +function PermissionGroup({ + groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState, +}: { + groupName: string; + permissions: lib.Permission[]; + canUpdate: boolean; + + baselineState: Record; + permissionState: Record; + setPermissionState: React.Dispatch>>; + setEdited?: (value: boolean) => void; +}) { + function setGroup(value: boolean) { + setPermissionState((prev: any) => { + const next = { ...prev }; + for (let p of permissions) { + next[p.name] = value; + } + return next; + }); + } + + return ( +
+ + {formatGroupTitle(groupName)} + + + + + + +
+ {permissions.map((p: any) => ( + setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))} + /> + ))} +
+
+ ); +} + +/** The role page view */ export default function RoleViewPage() { - let params = useParams(); - let roleId = Number(params.id); + const params = useParams(); + const roleId = Number(params.id); - let navigate = useNavigate(); - - let account = useAccount(); - let control = useContext(ControlContext); + const navigate = useNavigate(); + const account = useAccount(); + const control = useContext(ControlContext); const [role, synced] = useRole(roleId); - let [edited, setEdited] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [permissionState, setPermissionState] = useState>({}); + const [baselineState, setBaselineState] = useState>({}); + const [search, setSearch] = useState(""); - let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }]; + const allPermissions = useMemo(() => [...lib.permissions.values()], []); + const canUpdate = Boolean(account.hasPermission("core.role.update")); + + useEffect(() => { + if (!role) { + return; + } + + const base = Object.fromEntries( + allPermissions.map(p => [p.name, role.permissions.has(p.name)]) + ); + + setName(role.name); + setDescription(role.description); + setPermissionState(base); + setBaselineState(base); + }, [role, allPermissions]); + + // If no role then display either the loading screen or the not found screen if (!role) { + const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }]; + if (!synced) { return @@ -40,81 +185,136 @@ export default function RoleViewPage() { ; } - // TODO: Update displayed perms when role perms change and have not been edited locally - let initialValues = { - name: role["name"], - description: role["description"], - permissions: { - ...Object.fromEntries([...lib.permissions.values()].map(perm => [ - perm.name, [...role.permissions].includes(perm.name), - ])), - }, - }; + // Apply the filter by searching all permission fields + const filtered = allPermissions.filter(p => { + const q = search.toLowerCase(); + return p.name.toLowerCase().includes(q) + || p.title.toLowerCase().includes(q) + || p.description.toLowerCase().includes(q); + }); - let canUpdate = account.hasPermission("core.role.update"); - return -
{ setEdited(true); }} - onFinish={values => { - let newPermissions = []; - for (let [name, value] of Object.entries(values.permissions)) { - if (value) { newPermissions.push(name); } - } + // Organise the permissions into groups + const groups = new Map(); + for (let p of filtered) { + const g = getGroupName(p.name); + if (!groups.has(g)) { + groups.set(g, []); + } + groups.get(g)!.push(p); + } - control.send( - new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions) - ).then(() => { - setEdited(false); - }).catch(notifyErrorHandler("Error applying changes")); - }} - > + // Sort permissions alphabetically within their groups + const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); + for (let [, permissions] of sortedGroups) { + permissions.sort((a, b) => a.title.localeCompare(b.title)); + } + + // Check if any changes have been made to the permissions or role metadata + const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]); + const metaChanged = name !== role.name || description !== role.description; + const edited = permissionChanged || metaChanged; + + /** Applies all pending changes */ + function applyChanges() { + const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k); + control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions)) + .then(() => { + setBaselineState(permissionState); + }) + .catch(notifyErrorHandler("Error applying changes")); + } + + /** Revert any pending changes */ + function revertChanges() { + setPermissionState(baselineState); + if (role) { + setName(role.name); + setDescription(role.description); + } + } + + return ( + - {canUpdate && } {account.hasPermission("core.role.delete") && { - control.send( - new lib.RoleDeleteRequest(roleId) - ).then(() => { - navigate("/roles"); - }).catch(notifyErrorHandler("Error deleting role")); + control.send(new lib.RoleDeleteRequest(roleId)) + .then(() => navigate("/roles")) + .catch(notifyErrorHandler("Error deleting role")); }} > - + } } /> - - - - - - + + {edited && ( +
+ + You have unsaved changes + + + + + + + +
+ )} + +
+ + setName(e.target.value)} /> +
+ +
+ + setDescription(e.target.value)} /> +
+

Permissions

- {[...lib.permissions.values()].map(({name, title, description}) => ( - - - - ))} - - -
; + + setSearch(e.target.value)} style={{ marginBottom: 16 }} /> + + + {sortedGroups.map(([groupName, permissions]) => ( + + ))} + + + +
+ ); }