From bb788f33da8f6d510ebd9092caddff92aed9ba2a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 25 May 2026 20:43:51 +0100 Subject: [PATCH] Final Cleanup --- exp_groups/controller.ts | 2 +- exp_groups/index.ts | 9 +- exp_groups/instance.ts | 33 ++++-- exp_groups/module/control.lua | 26 +++-- exp_groups/module/module.json | 1 + exp_groups/web/components/AssignmentForm.tsx | 25 +++- .../web/components/AssignmentsTable.tsx | 94 +++++++++++---- exp_groups/web/components/DeleteConfirm.tsx | 16 +++ exp_groups/web/components/GroupForm.tsx | 42 +++---- exp_groups/web/components/GroupViewPage.tsx | 60 +++++----- exp_groups/web/components/GroupsTable.tsx | 21 ++-- exp_groups/web/components/RoleMappingForm.tsx | 90 +++++++++------ .../web/components/RoleMappingsTable.tsx | 107 ++++++++++++------ exp_groups/web/index.tsx | 8 +- 14 files changed, 350 insertions(+), 184 deletions(-) create mode 100644 exp_groups/web/components/DeleteConfirm.tsx diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 10bfacd2..6f94b2c0 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -397,7 +397,7 @@ export class ControllerPlugin extends BaseControllerPlugin { return manual; } - const user = this.controller.users.get(playerName); + const user = this.controller.users.getByName(playerName); const userRoles = user?.roleIds ?? new Set(); // 2) Role mappings diff --git a/exp_groups/index.ts b/exp_groups/index.ts index e16f361b..524ac7d9 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -131,6 +131,11 @@ export const plugin: lib.PluginDeclaration = { title: "ExpGaming - Permission Groups", description: "Clusterio plugin providing syncing of permission groups", + features: [ + "SavePatching", + "ScriptCommands", + ], + messages: [ messages.GroupUpdatedEvent, messages.ManualAssignmentUpdatedEvent, @@ -172,7 +177,7 @@ export const plugin: lib.PluginDeclaration = { webEntrypoint: "./web", routes: [ - "/exp_groups", - "/exp_groups/:id/view", + "/permission_groups", + "/permission_groups/:id/view", ] }; diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 1bfc66ba..516c34bf 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -5,7 +5,7 @@ import * as messages from "./messages"; export type IpcGroupUpdated = { group_name: string, group_id: number | undefined, - permissions: { is_blacklist: boolean, permissions: string[] }, + permissions: { is_blacklist: boolean, permissions: string[] | undefined }, }; export type IpcGroupDeleted = { @@ -21,6 +21,8 @@ export class InstancePlugin extends BaseInstancePlugin { // Once only, don't send permissions for these groups // This is used for groups created on this instance that only need the controller generated id skipSendingPermissions = new Set(); + // This is used for groups updated / deleted on this instance to stop cycles + skipSendingUpdate = new Set(); // Track known online players so that we only apply assignment updates for them onlinePlayers = new Set(); @@ -80,8 +82,8 @@ export class InstancePlugin extends BaseInstancePlugin { async handleGroupUpdatedIPC(event: IpcGroupUpdated) { const permissions = new messages.GroupPermissions( event.permissions.is_blacklist, - event.permissions.permissions, - ); + event.permissions.permissions ?? [], + ) if (event.group_id === undefined) { this.skipSendingPermissions.add(event.group_name); @@ -89,6 +91,7 @@ export class InstancePlugin extends BaseInstancePlugin { new messages.GroupCreateRequest(event.group_name, permissions), ); } else { + this.skipSendingUpdate.add(event.group_id); await this.instance.sendTo("controller", new messages.GroupUpdateRequest( new messages.GroupRecord(event.group_id, event.group_name, permissions), )); @@ -99,6 +102,7 @@ export class InstancePlugin extends BaseInstancePlugin { if (event.group_id === undefined) { return; } + this.skipSendingUpdate.add(event.group_id); await this.instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id)); } @@ -125,14 +129,19 @@ export class InstancePlugin extends BaseInstancePlugin { } async luaSendInitialGroups(groups: messages.GroupRecord[]) { - if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { + if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { return; } - await this.instance.sendRcon(`/sc exp_groups.initialise_groups(helpers.json_to_table${JSON.stringify(groups)})`) + await this.luaSend("initialise_groups", groups); } async luaSendGroupUpdate(group: messages.GroupRecord) { - if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { + if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { + return; + } + + if (this.skipSendingUpdate.has(group.id)) { + this.skipSendingUpdate.delete(group.id); return; } @@ -142,17 +151,21 @@ export class InstancePlugin extends BaseInstancePlugin { delete (json as any).permissions; } - await this.instance.sendRcon(`/sc exp_groups.receive_group_update(helpers.json_to_table${JSON.stringify(json)})`) + await this.luaSend("receive_group_update", json); } async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) { - if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { + if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { return; } - await this.instance.sendRcon(`/sc exp_groups.initialise_groups(helpers.receive_assignment_update${JSON.stringify(assignment)})`) + await this.luaSend("receive_assignment_update", assignment); } async luaSetEmitEvents(emitEvents: boolean) { - await this.instance.sendRcon(`/sc exp_groups.set_emit_events(${emitEvents})`) + await this.luaSend("set_emit_events", emitEvents); + } + + async luaSend(receiver: string, json: any) { + await this.instance.sendRcon(`/c exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true) } } diff --git a/exp_groups/module/control.lua b/exp_groups/module/control.lua index 3a3141e7..0c61af71 100644 --- a/exp_groups/module/control.lua +++ b/exp_groups/module/control.lua @@ -11,7 +11,7 @@ local ExpGroups = {} --- @class ExpPermissionGroups.GroupPermissions --- @field is_blacklist boolean ---- @field permissions string[] +--- @field permissions string[]? --- @class ExpPermissionGroups.GroupRecord --- @field id number @@ -21,8 +21,8 @@ local ExpGroups = {} --- @class ExpPermissionGroups.AssignmentRecord --- @field name string ---- @field groupId number ---- @field isDeleted boolean +--- @field group_id number +--- @field is_deleted boolean --- @class ExpPermissionGroups.ScriptData --- @field factorio_to_clusterio_id table @@ -73,7 +73,7 @@ end local function decode_group_permissions(group, permissions) -- Construct a hash map for faster lookup local action_map = {} - for _, input_action_name in pairs(permissions.permissions) do + for _, input_action_name in pairs(assert(permissions.permissions)) do action_map[input_action_name] = true end @@ -112,11 +112,15 @@ local function encode_group_permissions(group) -- Return the whitelist if it is smaller if blacklist_index > whitelist_index then - return { is_blacklist = false, permissions = whitelist } + return whitelist_index > 0 + and { is_blacklist = false, permissions = whitelist } + or { is_blacklist = false } end -- Otherwise return the blacklist as it is smaller - return { is_blacklist = true, permissions = blacklist } + return blacklist_index > 0 + and { is_blacklist = true, permissions = blacklist } + or { is_blacklist = true } end --[[ @@ -167,9 +171,9 @@ end --- Update an assignment by moving the player to their new group --- @param assignment_record ExpPermissionGroups.AssignmentRecord local function update_assignment(assignment_record) - assert(not assignment_record.isDeleted) + assert(not assignment_record.is_deleted) - local group = script_data.clusterio_id_to_group[assignment_record.groupId] + local group = script_data.clusterio_id_to_group[assignment_record.group_id] local player = assert(game.get_player(assignment_record.name)) if group then group.add_player(player) @@ -179,7 +183,7 @@ end --- Clear an assignment by moving the player to the default group --- @param assignment_record ExpPermissionGroups.AssignmentRecord local function delete_assignment(assignment_record) - assert(assignment_record.isDeleted) + assert(assignment_record.is_deleted) local default_group = get_default_group() local player = assert(game.get_player(assignment_record.name)) @@ -258,7 +262,7 @@ function ExpGroups.receive_assignment_update(assignment_record) local _emit_events = script_data.emit_updates script_data.emit_updates = false - if assignment_record.isDeleted then + if assignment_record.is_deleted then delete_assignment(assignment_record) else update_assignment(assignment_record) @@ -378,7 +382,7 @@ local function on_permission_group_added(event) return end - emit_group_update(event.group) + mark_group_dirty(event.group) end --- Handle deletion of permission groups diff --git a/exp_groups/module/module.json b/exp_groups/module/module.json index 0d7ebefd..6d8893a7 100644 --- a/exp_groups/module/module.json +++ b/exp_groups/module/module.json @@ -4,6 +4,7 @@ "control.lua" ], "require": [ + "globals.lua" ], "dependencies": { "clusterio": "*" diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx index fc280ddb..41a6a8f2 100644 --- a/exp_groups/web/components/AssignmentForm.tsx +++ b/exp_groups/web/components/AssignmentForm.tsx @@ -1,12 +1,17 @@ -import React, { useContext } from "react"; +import React, { useContext, useEffect } from "react"; import { Modal, Form, Select } from "antd"; import { ControlContext, useUsers } from "@clusterio/web_ui"; import * as messages from "../../messages"; +import type { WebPlugin } from ".."; -export default function AssignmentForm({ open, setOpen, initial }: any) { +export default function AssignmentForm({ open, setOpen, initial }: { + open: boolean, + setOpen: (open: boolean) => void, + initial?: messages.AssignmentRecord, +}) { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as any; + const plugin = control.plugins.get("exp_groups") as WebPlugin; const [groups] = plugin.useGroups(); const [users] = useUsers(); @@ -16,7 +21,10 @@ export default function AssignmentForm({ open, setOpen, initial }: any) { function submit(values: any) { if (initial) { control.send(new messages.AssignmentUpdateRequest( - new messages.AssignmentRecord(values.name, values.groupId) + new messages.AssignmentRecord( + values.name, + values.groupId, + ) )); } else { control.send(new messages.AssignmentCreateRequest( @@ -28,13 +36,20 @@ export default function AssignmentForm({ open, setOpen, initial }: any) { setOpen(false); } + useEffect(() => { + if (open) { + form.resetFields(); + form.setFieldsValue(initial); + } + }, [open, initial]); + return setOpen(false)} onOk={() => form.submit()} > -
+ + diff --git a/exp_groups/web/components/GroupViewPage.tsx b/exp_groups/web/components/GroupViewPage.tsx index 2845aa1f..7574cfa2 100644 --- a/exp_groups/web/components/GroupViewPage.tsx +++ b/exp_groups/web/components/GroupViewPage.tsx @@ -1,13 +1,14 @@ import React, { useContext, useEffect, useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, 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 { ControlContext, useAccount, useDefaultModPack, PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui"; +import DeletedConfirm from "./DeleteConfirm"; import * as messages from "../../messages"; +import type { WebPlugin } from ".."; -const DOMAIN_RULES: Record = { +const DOMAIN_MAPPING = { "Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"], "Building & Crafting": ["mining", "build", "craft", "deconstruct", "rotate", "entity"], "Inventory": ["inventory", "stack", "cursor", "slot", "quick_bar", "item", "equipment"], @@ -24,7 +25,7 @@ const DOMAIN_RULES: Record = { function getDomain(name: string) { const n = name.toLowerCase(); - for (const [domain, keywords] of Object.entries(DOMAIN_RULES)) { + for (const [domain, keywords] of Object.entries(DOMAIN_MAPPING)) { if (keywords.some(k => n.includes(k))) return domain; } @@ -33,7 +34,8 @@ function getDomain(name: string) { export default function GroupViewPage() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as any; + const plugin = control.plugins.get("exp_groups") as WebPlugin; + const navigate = useNavigate(); const account = useAccount(); const { id } = useParams(); @@ -52,6 +54,7 @@ export default function GroupViewPage() { const [search, setSearch] = useState(""); const canUpdate = Boolean(account.hasPermission("exp_groups.group.update")); + const canDelete = Boolean(account.hasPermission("exp_groups.group.delete")); // fetch defines useEffect(() => { @@ -59,16 +62,12 @@ export default function GroupViewPage() { 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); - } - })(); + const response = await fetch(`${staticRoot}static/${assetName}`); + const json = await response.json(); + setDefinesJson(json.input_action ?? {}); + })().catch(notifyErrorHandler("Failed to load permissions")); }, [defaultModPack]); // initialise permissions @@ -94,17 +93,13 @@ export default function GroupViewPage() { }, [group]); // derived data - const allPermissions = useMemo( - () => Object.keys(definesJson ?? {}), - [definesJson] - ); + const allPermissions = useMemo(() => ( + Object.keys(definesJson ?? {}) + ), [definesJson]); - const filtered = useMemo( - () => allPermissions.filter(p => - p.toLowerCase().includes(search.toLowerCase()) - ), - [allPermissions, search] - ); + const filtered = useMemo(() => allPermissions.filter(p => + p.toLowerCase().includes(search.toLowerCase()) + ), [allPermissions, search]); const grouped = useMemo(() => { const map = new Map(); @@ -169,8 +164,16 @@ export default function GroupViewPage() { ; } - return - + return + + {canDelete && { + control.send(new messages.GroupDeleteRequest(group.id)); + navigate(`/permission_groups`); + }}/>} + } + /> {edited && (
)} diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx index 08aba64c..1dc711d9 100644 --- a/exp_groups/web/components/GroupsTable.tsx +++ b/exp_groups/web/components/GroupsTable.tsx @@ -2,7 +2,9 @@ import React, { useContext, useState } from "react"; import { Table } from "antd"; import { useNavigate } from "react-router-dom"; -import { ControlContext, useAccount } from "@clusterio/web_ui"; +import { ControlContext } from "@clusterio/web_ui"; +import { GroupRecord } from "../../messages"; +import type { WebPlugin } from ".."; import GroupForm from "./GroupForm"; @@ -10,7 +12,7 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" export default function GroupsTable() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as any; + const plugin = control.plugins.get("exp_groups") as WebPlugin; const navigate = useNavigate(); const [groups, synced] = plugin.useGroups(); @@ -23,23 +25,22 @@ export default function GroupsTable() { { 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", + sorter: (a: any, b: GroupRecord) => strcmp(a.name, b.name), }, { title: "Permissions", - render: (_: any, g: any) => g.permissions.permissions.length, + width: "50%", + render: (_: any, g: GroupRecord) => ( + `${g.permissions.permissions.length} ${g.permissions.isBlacklist ? "Disallowed" : "Allowed"}` + ), }, ]} dataSource={[...groups.values()]} loading={!synced} - pagination={false} rowKey={(g) => g.id} + pagination={false} onRow={(g) => ({ - onClick: () => navigate(`/exp_groups/${g.id}/view`), + onClick: () => navigate(`/permission_groups/${g.id}/view`), })} /> diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx index d6801f7d..ee143301 100644 --- a/exp_groups/web/components/RoleMappingForm.tsx +++ b/exp_groups/web/components/RoleMappingForm.tsx @@ -1,12 +1,17 @@ -import React, { useContext } from "react"; -import { Modal, Form, Select, InputNumber, Switch } from "antd"; +import React, { useContext, useEffect } from "react"; +import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd"; import { ControlContext, useRoles } from "@clusterio/web_ui"; -import * as messages from "../../messages"; +import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages"; +import type { WebPlugin } from ".."; -export default function RoleMappingForm({ open, setOpen, initial }: any) { +export default function RoleMappingForm({ open, setOpen, initial }: { + open: boolean, + setOpen: (open: boolean) => void, + initial?: RoleMappingRecord, +}) { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as any; + const plugin = control.plugins.get("exp_groups") as WebPlugin; const [groups] = plugin.useGroups(); const [roles] = useRoles(); @@ -14,18 +19,18 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) { 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)); + control.send(new RoleMappingUpdateRequest( + new RoleMappingRecord( + initial.id, + new Set(values.roleIds), + values.groupId, + values.priority, + values.enabled, + ) + )); } else { - control.send(new messages.RoleMappingCreateRequest( + control.send(new RoleMappingCreateRequest( values.roleIds, values.groupId, values.priority, @@ -36,30 +41,39 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) { setOpen(false); } + useEffect(() => { + if (open) { + form.resetFields(); + form.setFieldsValue({ + ...initial, + roleIds: initial ? [...initial.roleIds] : [], + }); + } + }, [open, initial]); + return setOpen(false)} onOk={() => form.submit()} > - - - - + +
  • Manual assignments always take priority.
  • +
  • Mappings are checked in priority order (highest first).
  • +
  • A mapping applies only if the user has all listed roles.
  • +
  • The first matching mapping assigns the group.
  • +
  • If none match, the player is given all permissions.
  • + + } + /> + - + + + + + diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx index 587c6cfa..05877426 100644 --- a/exp_groups/web/components/RoleMappingsTable.tsx +++ b/exp_groups/web/components/RoleMappingsTable.tsx @@ -1,72 +1,105 @@ import React, { useContext, useState } from "react"; -import { Table, Button, Popconfirm, Space, Tag } from "antd"; -import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; +import { Table, Button, Space, Tag } from "antd"; +import { EditOutlined } from "@ant-design/icons"; import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui"; -import * as messages from "../../messages"; +import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages"; +import type { WebPlugin } from ".."; import RoleMappingForm from "./RoleMappingForm"; +import DeletedConfirm from "./DeleteConfirm"; export default function RoleMappingsTable() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as any; + const plugin = control.plugins.get("exp_groups") as WebPlugin; const account = useAccount(); - const [roleMappings, synced] = plugin.useRoleMappings(); - const [groups] = plugin.useGroups(); - const [roles] = useRoles(); + const [roleMappings, roleMappingsSynced] = plugin.useRoleMappings(); + const [groups, groupsSynced] = plugin.useGroups(); + const [roles, rolesSynced] = useRoles(); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState(); const [open, setOpen] = useState(false); + const roleMappingArray = [...roleMappings.values()]; + + const roleFilters = [...roles.values()] + .filter(role => roleMappingArray.some(mapping => mapping.roleIds.has(role.id))) + .map(role => ({ text: role.name, value: role.id })); + + const groupFilters = [...groups.values()] + .filter(group => roleMappingArray.some(mapping => mapping.groupId === group.id)) + .map(group => ({ text: group.name, value: group.id })) + return <> [...m.roleIds].map((id: number) => { - const role = roles.get(id); - return {role?.name ?? id}; - }), + filters: roleFilters, + onFilter: (value, record: RoleMappingRecord) => ( + record.roleIds.has(value as number) + ), + render: (_: any, record: RoleMappingRecord) => ( + [...record.roleIds].map(id => {roles.get(id)?.name ?? id}) + ), }, { title: "Group", - render: (_: any, m: any) => groups.get(m.groupId)?.name ?? m.groupId, + width: "30%", + filters: groupFilters, + onFilter: (value, record: RoleMappingRecord) => ( + record.groupId === value + ), + render: (_: any, record: RoleMappingRecord) => ( + groups.get(record.groupId)?.name ?? record.groupId + ), }, { title: "Enabled", - render: (_: any, m: any) => m.enabled ? "Yes" : "No", - }, - { - title: "Actions", - render: (_: any, record: any) => ( - - {account.hasPermission("exp_groups.role_mapping.update") && -