From 2248f23a8d6363371f453223cfd1fadab5462d02 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 23 May 2026 03:51:16 +0100 Subject: [PATCH] Added web UI --- exp_groups/controller.ts | 38 ++- exp_groups/index.ts | 7 + exp_groups/messages.ts | 3 + exp_groups/module/control.lua | 1 + exp_groups/tsconfig.browser.json | 2 +- exp_groups/web/components/AssignmentForm.tsx | 59 ++++ .../web/components/AssignmentsTable.tsx | 65 ++++ exp_groups/web/components/GroupForm.tsx | 41 +++ exp_groups/web/components/GroupViewPage.tsx | 283 ++++++++++++++++++ exp_groups/web/components/GroupsTable.tsx | 48 +++ exp_groups/web/components/RoleMappingForm.tsx | 82 +++++ .../web/components/RoleMappingsTable.tsx | 74 +++++ exp_groups/web/index.tsx | 108 +++++++ exp_groups/webpack.config.js | 5 + 14 files changed, 804 insertions(+), 12 deletions(-) create mode 100644 exp_groups/web/components/AssignmentForm.tsx create mode 100644 exp_groups/web/components/AssignmentsTable.tsx create mode 100644 exp_groups/web/components/GroupForm.tsx create mode 100644 exp_groups/web/components/GroupViewPage.tsx create mode 100644 exp_groups/web/components/GroupsTable.tsx create mode 100644 exp_groups/web/components/RoleMappingForm.tsx create mode 100644 exp_groups/web/components/RoleMappingsTable.tsx diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index bdc1860b..10bfacd2 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -228,6 +228,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const group = new messages.GroupRecord(id, request.name, request.permissions); this.groups.set(group); + return group; } async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) { @@ -275,6 +276,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const assignment = new messages.AssignmentRecord(name, groupId); this.manualAssignments.set(assignment); + return assignment; } async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) { @@ -330,24 +332,38 @@ export class ControllerPlugin extends BaseControllerPlugin { id = Math.random() * 2**31 | 0; } - const mapping = new messages.RoleMappingRecord( - id, - new Set(request.roleIds), - request.groupId, - request.priority, - request.enabled, + let priority = request.priority; + const existing = new Set([...this.roleMappings.values()].map(m => m.priority)); + while (existing.has(priority)) { + priority++; + } + + 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) { - const mapping = request.roleMapping; - if (mapping.id === undefined || !this.roleMappings.has(mapping.id)) { - throw new lib.RequestError(`Role mapping with ID ${mapping.id} does not exist`); + const roleMapping = request.roleMapping; + if (roleMapping.id === undefined || !this.roleMappings.has(roleMapping.id)) { + 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) { diff --git a/exp_groups/index.ts b/exp_groups/index.ts index 2b433065..e16f361b 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -134,6 +134,7 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.GroupUpdatedEvent, messages.ManualAssignmentUpdatedEvent, + messages.ResolvedAssignmentUpdatedEvent, messages.RoleMappingUpdatedEvent, messages.GroupCreateRequest, @@ -168,4 +169,10 @@ export const plugin: lib.PluginDeclaration = { controllerEntrypoint: "./dist/node/controller", controllerConfigFields: { }, + + webEntrypoint: "./web", + routes: [ + "/exp_groups", + "/exp_groups/:id/view", + ] }; diff --git a/exp_groups/messages.ts b/exp_groups/messages.ts index 691d8ca3..b0e5e986 100644 --- a/exp_groups/messages.ts +++ b/exp_groups/messages.ts @@ -292,6 +292,7 @@ export class GroupCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.group.create" as const; + static Response = GroupRecord; constructor( public name: string, @@ -425,6 +426,7 @@ export class AssignmentCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.assignment.create" as const; + static Response = AssignmentRecord; constructor( public name: string, @@ -558,6 +560,7 @@ export class RoleMappingCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.role_mapping.create" as const; + static Response = RoleMappingRecord; constructor( public roleIds: number[], diff --git a/exp_groups/module/control.lua b/exp_groups/module/control.lua index 2a854bdb..3a3141e7 100644 --- a/exp_groups/module/control.lua +++ b/exp_groups/module/control.lua @@ -419,6 +419,7 @@ local e = defines.events local events = { [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_deleted] = on_permission_group_deleted, [e.on_permission_group_edited] = on_permission_group_edited, diff --git a/exp_groups/tsconfig.browser.json b/exp_groups/tsconfig.browser.json index 1ab2704e..1e3889e7 100644 --- a/exp_groups/tsconfig.browser.json +++ b/exp_groups/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], } diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx new file mode 100644 index 00000000..fc280ddb --- /dev/null +++ b/exp_groups/web/components/AssignmentForm.tsx @@ -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 setOpen(false)} + onOk={() => form.submit()} + > +
+ + + + + + + +
+
; +} diff --git a/exp_groups/web/components/AssignmentsTable.tsx b/exp_groups/web/components/AssignmentsTable.tsx new file mode 100644 index 00000000..559f4972 --- /dev/null +++ b/exp_groups/web/components/AssignmentsTable.tsx @@ -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(null); + const [open, setOpen] = useState(false); + + return <> + strcmp(a.name, b.name), + }, + { + title: "Group", + render: (_: any, a: any) => groups.get(a.groupId)?.name ?? a.groupId, + }, + { + title: "Actions", + render: (_: any, record: any) => ( + + {account.hasPermission("exp_groups.assignment.update") && + + + + + )} + +
+ + setName(e.target.value)} /> +
+ +

Permissions

+ + {!defaultModPack?.exportManifest?.assets?.defines && ( + + )} + + {defaultModPack?.exportManifest?.assets?.defines && !definesJson && } + + {definesJson && <> + setSearch(e.target.value)} + style={{ marginBottom: 16 }} + /> + + + {grouped.map(([groupName, permissions]) => ( +
+ + + {groupName} + + + + + + + +
+ {permissions.map(p => ( + + setPermissionState(prev => ({ ...prev, [p]: e.target.checked })) + } + > + {p} + + ))} +
+
+ ))} +
+ } + ; +} diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx new file mode 100644 index 00000000..08aba64c --- /dev/null +++ b/exp_groups/web/components/GroupsTable.tsx @@ -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 <> +
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`), + })} + /> + + + ; +} \ No newline at end of file diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx new file mode 100644 index 00000000..d6801f7d --- /dev/null +++ b/exp_groups/web/components/RoleMappingForm.tsx @@ -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 setOpen(false)} + onOk={() => form.submit()} + > +
+ + + + + + + + + + + + + + + + +
; +} diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx new file mode 100644 index 00000000..587c6cfa --- /dev/null +++ b/exp_groups/web/components/RoleMappingsTable.tsx @@ -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(null); + const [open, setOpen] = useState(false); + + return <> +
[...m.roleIds].map((id: number) => { + const role = roles.get(id); + return {role?.name ?? id}; + }), + }, + { + 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) => ( + + {account.hasPermission("exp_groups.role_mapping.update") && + + : undefined + } + /> + + + } + + {account.hasPermission("exp_groups.role_mapping.list") && <> + setRoleMappingOpen(true)}>Create + : undefined + } + /> + + + } + + {account.hasPermission("exp_groups.assignment.list") && <> + setAssignmentOpen(true)}>Create + : undefined + } + /> + + + } + ; +} + +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: , + }, + { + path: "/exp_groups/:id/view", + sidebarPath: "/exp_groups", + permission: "exp_groups.group.get", + content: , + }, + ]; + } + + 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()); + } +} diff --git a/exp_groups/webpack.config.js b/exp_groups/webpack.config.js index 0baa40ac..b1f01165 100644 --- a/exp_groups/webpack.config.js +++ b/exp_groups/webpack.config.js @@ -23,6 +23,11 @@ module.exports = (env = {}) => merge(common(env), { shared: { "@clusterio/lib": { 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 }, }, }), ],