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
-
-
- ;
+
+ setSearch(e.target.value)} style={{ marginBottom: 16 }} />
+
+
+ {sortedGroups.map(([groupName, permissions]) => (
+
+ ))}
+
+
+
+
+ );
}