mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-07-26 18:36:23 +09:00
Added web UI
This commit is contained in:
+27
-11
@@ -228,6 +228,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
|||||||
|
|
||||||
const group = new messages.GroupRecord(id, request.name, request.permissions);
|
const group = new messages.GroupRecord(id, request.name, request.permissions);
|
||||||
this.groups.set(group);
|
this.groups.set(group);
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) {
|
async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) {
|
||||||
@@ -275,6 +276,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
|||||||
|
|
||||||
const assignment = new messages.AssignmentRecord(name, groupId);
|
const assignment = new messages.AssignmentRecord(name, groupId);
|
||||||
this.manualAssignments.set(assignment);
|
this.manualAssignments.set(assignment);
|
||||||
|
return assignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) {
|
async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) {
|
||||||
@@ -330,24 +332,38 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
|||||||
id = Math.random() * 2**31 | 0;
|
id = Math.random() * 2**31 | 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapping = new messages.RoleMappingRecord(
|
let priority = request.priority;
|
||||||
id,
|
const existing = new Set([...this.roleMappings.values()].map(m => m.priority));
|
||||||
new Set(request.roleIds),
|
while (existing.has(priority)) {
|
||||||
request.groupId,
|
priority++;
|
||||||
request.priority,
|
}
|
||||||
request.enabled,
|
|
||||||
|
const roleMapping = new messages.RoleMappingRecord(
|
||||||
|
id, new Set(request.roleIds), request.groupId, priority, request.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.roleMappings.set(mapping);
|
this.roleMappings.set(roleMapping);
|
||||||
|
return roleMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRoleMappingUpdateRequest(request: messages.RoleMappingUpdateRequest) {
|
async handleRoleMappingUpdateRequest(request: messages.RoleMappingUpdateRequest) {
|
||||||
const mapping = request.roleMapping;
|
const roleMapping = request.roleMapping;
|
||||||
if (mapping.id === undefined || !this.roleMappings.has(mapping.id)) {
|
if (roleMapping.id === undefined || !this.roleMappings.has(roleMapping.id)) {
|
||||||
throw new lib.RequestError(`Role mapping with ID ${mapping.id} does not exist`);
|
throw new lib.RequestError(`Role mapping with ID ${roleMapping.id} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.roleMappings.set(mapping);
|
const existing = new Set(
|
||||||
|
[...this.roleMappings.values()]
|
||||||
|
.filter(m => m.id !== roleMapping.id)
|
||||||
|
.map(m => m.priority)
|
||||||
|
);
|
||||||
|
|
||||||
|
let priority = roleMapping.priority;
|
||||||
|
while (existing.has(priority)) {
|
||||||
|
priority++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.roleMappings.set(roleMapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRoleMappingDeleteRequest(request: messages.RoleMappingDeleteRequest) {
|
async handleRoleMappingDeleteRequest(request: messages.RoleMappingDeleteRequest) {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export const plugin: lib.PluginDeclaration = {
|
|||||||
messages: [
|
messages: [
|
||||||
messages.GroupUpdatedEvent,
|
messages.GroupUpdatedEvent,
|
||||||
messages.ManualAssignmentUpdatedEvent,
|
messages.ManualAssignmentUpdatedEvent,
|
||||||
|
messages.ResolvedAssignmentUpdatedEvent,
|
||||||
messages.RoleMappingUpdatedEvent,
|
messages.RoleMappingUpdatedEvent,
|
||||||
|
|
||||||
messages.GroupCreateRequest,
|
messages.GroupCreateRequest,
|
||||||
@@ -168,4 +169,10 @@ export const plugin: lib.PluginDeclaration = {
|
|||||||
controllerEntrypoint: "./dist/node/controller",
|
controllerEntrypoint: "./dist/node/controller",
|
||||||
controllerConfigFields: {
|
controllerConfigFields: {
|
||||||
},
|
},
|
||||||
|
|
||||||
|
webEntrypoint: "./web",
|
||||||
|
routes: [
|
||||||
|
"/exp_groups",
|
||||||
|
"/exp_groups/:id/view",
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ export class GroupCreateRequest {
|
|||||||
static src = ["control", "instance"] as const;
|
static src = ["control", "instance"] as const;
|
||||||
static dst = "controller" as const;
|
static dst = "controller" as const;
|
||||||
static permission = "exp_groups.group.create" as const;
|
static permission = "exp_groups.group.create" as const;
|
||||||
|
static Response = GroupRecord;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public name: string,
|
public name: string,
|
||||||
@@ -425,6 +426,7 @@ export class AssignmentCreateRequest {
|
|||||||
static src = ["control", "instance"] as const;
|
static src = ["control", "instance"] as const;
|
||||||
static dst = "controller" as const;
|
static dst = "controller" as const;
|
||||||
static permission = "exp_groups.assignment.create" as const;
|
static permission = "exp_groups.assignment.create" as const;
|
||||||
|
static Response = AssignmentRecord;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public name: string,
|
public name: string,
|
||||||
@@ -558,6 +560,7 @@ export class RoleMappingCreateRequest {
|
|||||||
static src = ["control", "instance"] as const;
|
static src = ["control", "instance"] as const;
|
||||||
static dst = "controller" as const;
|
static dst = "controller" as const;
|
||||||
static permission = "exp_groups.role_mapping.create" as const;
|
static permission = "exp_groups.role_mapping.create" as const;
|
||||||
|
static Response = RoleMappingRecord;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public roleIds: number[],
|
public roleIds: number[],
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ local e = defines.events
|
|||||||
|
|
||||||
local events = {
|
local events = {
|
||||||
[clusterio_api.events.on_server_startup] = on_server_startup,
|
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||||
|
[e.on_multiplayer_init] = on_server_startup,
|
||||||
[e.on_permission_group_added] = on_permission_group_added,
|
[e.on_permission_group_added] = on_permission_group_added,
|
||||||
[e.on_permission_group_deleted] = on_permission_group_deleted,
|
[e.on_permission_group_deleted] = on_permission_group_deleted,
|
||||||
[e.on_permission_group_edited] = on_permission_group_edited,
|
[e.on_permission_group_edited] = on_permission_group_edited,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.browser.json",
|
"extends": "../tsconfig.browser.json",
|
||||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Modal, Form, Select } from "antd";
|
||||||
|
|
||||||
|
import { ControlContext, useUsers } from "@clusterio/web_ui";
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
export default function AssignmentForm({ open, setOpen, initial }: any) {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
const [users] = useUsers();
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
function submit(values: any) {
|
||||||
|
if (initial) {
|
||||||
|
control.send(new messages.AssignmentUpdateRequest(
|
||||||
|
new messages.AssignmentRecord(values.name, values.groupId)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
control.send(new messages.AssignmentCreateRequest(
|
||||||
|
values.name,
|
||||||
|
values.groupId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
title={initial ? "Edit Assignment" : "Create Assignment"}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
>
|
||||||
|
<Form form={form} initialValues={initial} onFinish={submit}>
|
||||||
|
<Form.Item name="name" label="Player" rules={[{ required: true }]}>
|
||||||
|
<Select disabled={Boolean(initial)} showSearch>
|
||||||
|
{[...users.values()].map(u => (
|
||||||
|
<Select.Option key={u.name} value={u.name}>
|
||||||
|
{u.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
{[...groups.values()].map(g => (
|
||||||
|
<Select.Option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { Table, Button, Popconfirm, Space } from "antd";
|
||||||
|
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { ControlContext, useAccount } from "@clusterio/web_ui";
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
import AssignmentForm from "./AssignmentForm";
|
||||||
|
|
||||||
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||||
|
|
||||||
|
export default function AssignmentsTable() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const [assignments, synced] = plugin.useAssignments();
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Player",
|
||||||
|
dataIndex: "name",
|
||||||
|
sorter: (a: any, b: any) => strcmp(a.name, b.name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Group",
|
||||||
|
render: (_: any, a: any) => groups.get(a.groupId)?.name ?? a.groupId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
{account.hasPermission("exp_groups.assignment.update") &&
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => {
|
||||||
|
setEditing(record);
|
||||||
|
setOpen(true);
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
{account.hasPermission("exp_groups.assignment.delete") &&
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete?"
|
||||||
|
onConfirm={() => control.send(new messages.AssignmentDeleteRequest(record.name))}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={[...assignments.values()]}
|
||||||
|
loading={!synced}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(a) => a.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AssignmentForm open={open} setOpen={setOpen} initial={editing} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Modal, Form, Input, Switch } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ControlContext } from "@clusterio/web_ui";
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
export default function GroupForm({ open, setOpen, initial }: any) {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function submit(values: any) {
|
||||||
|
if (initial) {
|
||||||
|
control.send(new messages.GroupUpdateRequest(
|
||||||
|
new messages.GroupRecord(
|
||||||
|
initial.id, values.name,
|
||||||
|
new messages.GroupPermissions(Boolean(values.isBlacklist), [])
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
const group = await control.send(new messages.GroupCreateRequest(
|
||||||
|
values.name, new messages.GroupPermissions(Boolean(values.isBlacklist), [])
|
||||||
|
));
|
||||||
|
navigate(`/exp_groups/${group.id}/view`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal title="Group" open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()}>
|
||||||
|
<Form form={form} initialValues={initial} onFinish={submit}>
|
||||||
|
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isBlacklist" label="Grant all by default" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Button, Checkbox, Input, Space, Spin, Alert } from "antd";
|
||||||
|
|
||||||
|
import { ControlContext, useAccount, useDefaultModPack } from "@clusterio/web_ui";
|
||||||
|
import { PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui";
|
||||||
|
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
const DOMAIN_RULES: Record<string, string[]> = {
|
||||||
|
"Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"],
|
||||||
|
"Building & Crafting": ["mining", "build", "craft", "deconstruct", "rotate", "entity"],
|
||||||
|
"Inventory": ["inventory", "stack", "cursor", "slot", "quick_bar", "item", "equipment"],
|
||||||
|
"Blueprints": ["blueprint", "copy", "paste", "undo", "redo", "upgrade"],
|
||||||
|
"Trains": ["train", "station", "schedule", "interrupt", "rolling_stock"],
|
||||||
|
"Circuit Network": ["combinator", "circuit", "signal", "wire", "cable", "speaker", "display_panel"],
|
||||||
|
"GUI": ["gui", "open"],
|
||||||
|
"Pins & Alerts": ["pin", "alert", "tag"],
|
||||||
|
"Logistics": ["logistic", "filter", "request"],
|
||||||
|
"Space": ["space", "surface", "planet", "rocket"],
|
||||||
|
"Toggles": ["toggle", "switch", "swap", "set"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDomain(name: string) {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
|
||||||
|
for (const [domain, keywords] of Object.entries(DOMAIN_RULES)) {
|
||||||
|
if (keywords.some(k => n.includes(k))) return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupViewPage() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const { id } = useParams();
|
||||||
|
const groupId = Number(id);
|
||||||
|
|
||||||
|
const [groups, synced] = plugin.useGroups();
|
||||||
|
const group = groups.get(groupId);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const defaultModPack = useDefaultModPack();
|
||||||
|
const [definesJson, setDefinesJson] = useState<Record<string, number> | null>(null);
|
||||||
|
|
||||||
|
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
|
||||||
|
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const canUpdate = Boolean(account.hasPermission("exp_groups.group.update"));
|
||||||
|
|
||||||
|
// fetch defines
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultModPack?.exportManifest?.assets?.defines) return;
|
||||||
|
if (definesJson) return;
|
||||||
|
|
||||||
|
const assetName = defaultModPack.exportManifest.assets.defines;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${staticRoot}static/${assetName}`);
|
||||||
|
const json = await response.json();
|
||||||
|
setDefinesJson(json.input_action ?? {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load defines", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [defaultModPack]);
|
||||||
|
|
||||||
|
// initialise permissions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group || !definesJson) return;
|
||||||
|
if (Object.keys(permissionState).length) return;
|
||||||
|
|
||||||
|
const { isBlacklist, permissions } = group.permissions;
|
||||||
|
|
||||||
|
const base = Object.fromEntries(
|
||||||
|
Object.keys(definesJson).map(p => [
|
||||||
|
p, isBlacklist !== permissions.includes(p)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
setPermissionState(base);
|
||||||
|
setBaselineState(base);
|
||||||
|
}, [group, definesJson]);
|
||||||
|
|
||||||
|
// sync name
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) setName(group.name);
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
// derived data
|
||||||
|
const allPermissions = useMemo(
|
||||||
|
() => Object.keys(definesJson ?? {}),
|
||||||
|
[definesJson]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => allPermissions.filter(p =>
|
||||||
|
p.toLowerCase().includes(search.toLowerCase())
|
||||||
|
),
|
||||||
|
[allPermissions, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const p of filtered) {
|
||||||
|
const domain = getDomain(p);
|
||||||
|
if (!map.has(domain)) map.set(domain, []);
|
||||||
|
map.get(domain)!.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
for (const [, perms] of sorted) {
|
||||||
|
perms.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
const permissionChanged = Object.keys(permissionState).some(
|
||||||
|
k => permissionState[k] !== baselineState[k]
|
||||||
|
);
|
||||||
|
const metaChanged = group ? name !== group.name : false;
|
||||||
|
const edited = permissionChanged || metaChanged;
|
||||||
|
|
||||||
|
function applyChanges() {
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const whitelist: string[] = [];
|
||||||
|
const blacklist: string[] = [];
|
||||||
|
|
||||||
|
for (const [perm, enabled] of Object.entries(permissionState)) {
|
||||||
|
if (enabled) whitelist.push(perm);
|
||||||
|
else blacklist.push(perm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBlacklist = blacklist.length < whitelist.length;
|
||||||
|
const permissions = isBlacklist ? blacklist : whitelist;
|
||||||
|
|
||||||
|
control.send(new messages.GroupUpdateRequest(
|
||||||
|
new messages.GroupRecord(group.id, name, new messages.GroupPermissions(isBlacklist, permissions))
|
||||||
|
))
|
||||||
|
.then(() => setBaselineState(permissionState))
|
||||||
|
.catch(notifyErrorHandler("Error applying changes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertChanges() {
|
||||||
|
if (!group) return;
|
||||||
|
setPermissionState(baselineState);
|
||||||
|
setName(group.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!synced) {
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
|
||||||
|
<Spin />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
|
||||||
|
<PageHeader title="Group not found" />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups", path: "/exp_groups" }, { name: group.name }]}>
|
||||||
|
<PageHeader title={group.name} />
|
||||||
|
|
||||||
|
{edited && (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
background: "#2a1912",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<span>You have unsaved changes</span>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={revertChanges}>Revert</Button>
|
||||||
|
<Button type="primary" onClick={applyChanges}>Apply</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16, display: "flex", gap: 12 }}>
|
||||||
|
<label style={{ width: 110 }}>Name</label>
|
||||||
|
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Permissions</h3>
|
||||||
|
|
||||||
|
{!defaultModPack?.exportManifest?.assets?.defines && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="Missing export"
|
||||||
|
description="An export of the default mod pack is required to see all available permissions."
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{defaultModPack?.exportManifest?.assets?.defines && !definesJson && <Spin />}
|
||||||
|
|
||||||
|
{definesJson && <>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Search permissions"
|
||||||
|
allowClear
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{grouped.map(([groupName, permissions]) => (
|
||||||
|
<div key={groupName} style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
|
||||||
|
|
||||||
|
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||||
|
<strong>{groupName}</strong>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
setPermissionState(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const p of permissions) next[p] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
setPermissionState(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const p of permissions) next[p] = false;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{permissions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p}
|
||||||
|
checked={permissionState[p]}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onChange={e =>
|
||||||
|
setPermissionState(prev => ({ ...prev, [p]: e.target.checked }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</>}
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { Table } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ControlContext, useAccount } from "@clusterio/web_ui";
|
||||||
|
|
||||||
|
import GroupForm from "./GroupForm";
|
||||||
|
|
||||||
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||||
|
|
||||||
|
export default function GroupsTable() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [groups, synced] = plugin.useGroups();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
dataIndex: "name",
|
||||||
|
sorter: (a: any, b: any) => strcmp(a.name, b.name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Type",
|
||||||
|
render: (_: any, g: any) => g.permissions.isBlacklist ? "Blacklist" : "Whitelist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Permissions",
|
||||||
|
render: (_: any, g: any) => g.permissions.permissions.length,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={[...groups.values()]}
|
||||||
|
loading={!synced}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(g) => g.id}
|
||||||
|
onRow={(g) => ({
|
||||||
|
onClick: () => navigate(`/exp_groups/${g.id}/view`),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupForm open={open} setOpen={setOpen} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Modal, Form, Select, InputNumber, Switch } from "antd";
|
||||||
|
|
||||||
|
import { ControlContext, useRoles } from "@clusterio/web_ui";
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
export default function RoleMappingForm({ open, setOpen, initial }: any) {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
const [roles] = useRoles();
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
function submit(values: any) {
|
||||||
|
const record = new messages.RoleMappingRecord(
|
||||||
|
initial?.id,
|
||||||
|
new Set(values.roleIds),
|
||||||
|
values.groupId,
|
||||||
|
values.priority,
|
||||||
|
values.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initial) {
|
||||||
|
control.send(new messages.RoleMappingUpdateRequest(record));
|
||||||
|
} else {
|
||||||
|
control.send(new messages.RoleMappingCreateRequest(
|
||||||
|
values.roleIds,
|
||||||
|
values.groupId,
|
||||||
|
values.priority,
|
||||||
|
values.enabled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
title={initial ? "Edit Role Mapping" : "Create Role Mapping"}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
initialValues={{
|
||||||
|
...initial,
|
||||||
|
roleIds: initial ? [...initial.roleIds] : [],
|
||||||
|
}}
|
||||||
|
onFinish={submit}
|
||||||
|
>
|
||||||
|
<Form.Item name="roleIds" label="Roles" rules={[{ required: true }]}>
|
||||||
|
<Select mode="multiple">
|
||||||
|
{[...roles.values()].map(r => (
|
||||||
|
<Select.Option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
{[...groups.values()].map(g => (
|
||||||
|
<Select.Option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="priority" label="Priority" initialValue={0}>
|
||||||
|
<InputNumber style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="enabled" label="Enabled" valuePropName="checked" initialValue={true}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { Table, Button, Popconfirm, Space, Tag } from "antd";
|
||||||
|
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui";
|
||||||
|
import * as messages from "../../messages";
|
||||||
|
|
||||||
|
import RoleMappingForm from "./RoleMappingForm";
|
||||||
|
|
||||||
|
export default function RoleMappingsTable() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as any;
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const [roleMappings, synced] = plugin.useRoleMappings();
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
const [roles] = useRoles();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Priority",
|
||||||
|
dataIndex: "priority",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Roles",
|
||||||
|
render: (_: any, m: any) => [...m.roleIds].map((id: number) => {
|
||||||
|
const role = roles.get(id);
|
||||||
|
return <Tag key={id}>{role?.name ?? id}</Tag>;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Group",
|
||||||
|
render: (_: any, m: any) => groups.get(m.groupId)?.name ?? m.groupId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Enabled",
|
||||||
|
render: (_: any, m: any) => m.enabled ? "Yes" : "No",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
{account.hasPermission("exp_groups.role_mapping.update") &&
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => {
|
||||||
|
setEditing(record);
|
||||||
|
setOpen(true);
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
{account.hasPermission("exp_groups.role_mapping.delete") &&
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete?"
|
||||||
|
onConfirm={() => control.send(new messages.RoleMappingDeleteRequest(record.id))}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={[...roleMappings.values()].sort((a, b) => b.priority - a.priority)}
|
||||||
|
loading={!synced}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(m) => m.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleMappingForm open={open} setOpen={setOpen} initial={editing} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useCallback, useSyncExternalStore } from "react";
|
||||||
|
import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui";
|
||||||
|
import { Button } from "antd";
|
||||||
|
|
||||||
|
import * as messages from "../messages";
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
import GroupsTable from "./components/GroupsTable";
|
||||||
|
import AssignmentsTable from "./components/AssignmentsTable";
|
||||||
|
import RoleMappingsTable from "./components/RoleMappingsTable";
|
||||||
|
import GroupViewPage from "./components/GroupViewPage";
|
||||||
|
import GroupForm from "./components/GroupForm";
|
||||||
|
import RoleMappingForm from "./components/RoleMappingForm";
|
||||||
|
import AssignmentForm from "./components/AssignmentForm";
|
||||||
|
|
||||||
|
|
||||||
|
function ExpGroupsPage() {
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
|
const [roleMappingOpen, setRoleMappingOpen] = useState(false);
|
||||||
|
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups" }]}>
|
||||||
|
<PageHeader title="Permission Groups" />
|
||||||
|
|
||||||
|
{account.hasPermission("exp_groups.group.list") && <>
|
||||||
|
<SectionHeader
|
||||||
|
title="Groups"
|
||||||
|
extra={
|
||||||
|
account.hasPermission("exp_groups.group.create")
|
||||||
|
? <Button type="primary" onClick={() => setGroupOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<GroupsTable />
|
||||||
|
<GroupForm open={groupOpen} setOpen={setGroupOpen} />
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{account.hasPermission("exp_groups.role_mapping.list") && <>
|
||||||
|
<SectionHeader
|
||||||
|
title="Role Mappings"
|
||||||
|
extra={
|
||||||
|
account.hasPermission("exp_groups.role_mapping.create")
|
||||||
|
? <Button type="primary" onClick={() => setRoleMappingOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RoleMappingsTable />
|
||||||
|
<RoleMappingForm open={roleMappingOpen} setOpen={setRoleMappingOpen} />
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{account.hasPermission("exp_groups.assignment.list") && <>
|
||||||
|
<SectionHeader
|
||||||
|
title="Assignments"
|
||||||
|
extra={
|
||||||
|
account.hasPermission("exp_groups.assignment.create")
|
||||||
|
? <Button type="primary" onClick={() => setAssignmentOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AssignmentsTable />
|
||||||
|
<AssignmentForm open={assignmentOpen} setOpen={setAssignmentOpen} />
|
||||||
|
</>}
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebPlugin extends BaseWebPlugin {
|
||||||
|
groups = new lib.EventSubscriber(messages.GroupUpdatedEvent, this.control);
|
||||||
|
assignments = new lib.EventSubscriber(messages.ManualAssignmentUpdatedEvent, this.control);
|
||||||
|
roleMappings = new lib.EventSubscriber(messages.RoleMappingUpdatedEvent, this.control);
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.pages = [
|
||||||
|
{
|
||||||
|
path: "/exp_groups",
|
||||||
|
sidebarName: "Permission Groups",
|
||||||
|
permission: (account => account.hasAnyPermission(
|
||||||
|
"exp_groups.group.list",
|
||||||
|
"exp_groups.assignment.list",
|
||||||
|
"exp_groups.role_mapping.list",
|
||||||
|
)),
|
||||||
|
content: <ExpGroupsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/exp_groups/:id/view",
|
||||||
|
sidebarPath: "/exp_groups",
|
||||||
|
permission: "exp_groups.group.get",
|
||||||
|
content: <GroupViewPage />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
useGroups() {
|
||||||
|
const subscribe = useCallback((cb: () => void) => this.groups.subscribe(cb), []);
|
||||||
|
return useSyncExternalStore(subscribe, () => this.groups.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
useAssignments() {
|
||||||
|
const subscribe = useCallback((cb: () => void) => this.assignments.subscribe(cb), []);
|
||||||
|
return useSyncExternalStore(subscribe, () => this.assignments.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
useRoleMappings() {
|
||||||
|
const subscribe = useCallback((cb: () => void) => this.roleMappings.subscribe(cb), []);
|
||||||
|
return useSyncExternalStore(subscribe, () => this.roleMappings.getSnapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ module.exports = (env = {}) => merge(common(env), {
|
|||||||
shared: {
|
shared: {
|
||||||
"@clusterio/lib": { import: false },
|
"@clusterio/lib": { import: false },
|
||||||
"@clusterio/web_ui": { import: false },
|
"@clusterio/web_ui": { import: false },
|
||||||
|
"antd": { import: false },
|
||||||
|
"react": { import: false },
|
||||||
|
"react-dom": { import: false },
|
||||||
|
"react-router": { import: false },
|
||||||
|
"react-router-dom": { import: false },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user