Final Cleanup

This commit is contained in:
Cooldude2606
2026-05-25 20:43:51 +01:00
parent 2248f23a8d
commit bb788f33da
14 changed files with 350 additions and 184 deletions
+1 -1
View File
@@ -397,7 +397,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
return manual; return manual;
} }
const user = this.controller.users.get(playerName); const user = this.controller.users.getByName(playerName);
const userRoles = user?.roleIds ?? new Set<number>(); const userRoles = user?.roleIds ?? new Set<number>();
// 2) Role mappings // 2) Role mappings
+7 -2
View File
@@ -131,6 +131,11 @@ export const plugin: lib.PluginDeclaration = {
title: "ExpGaming - Permission Groups", title: "ExpGaming - Permission Groups",
description: "Clusterio plugin providing syncing of permission groups", description: "Clusterio plugin providing syncing of permission groups",
features: [
"SavePatching",
"ScriptCommands",
],
messages: [ messages: [
messages.GroupUpdatedEvent, messages.GroupUpdatedEvent,
messages.ManualAssignmentUpdatedEvent, messages.ManualAssignmentUpdatedEvent,
@@ -172,7 +177,7 @@ export const plugin: lib.PluginDeclaration = {
webEntrypoint: "./web", webEntrypoint: "./web",
routes: [ routes: [
"/exp_groups", "/permission_groups",
"/exp_groups/:id/view", "/permission_groups/:id/view",
] ]
}; };
+23 -10
View File
@@ -5,7 +5,7 @@ import * as messages from "./messages";
export type IpcGroupUpdated = { export type IpcGroupUpdated = {
group_name: string, group_name: string,
group_id: number | undefined, group_id: number | undefined,
permissions: { is_blacklist: boolean, permissions: string[] }, permissions: { is_blacklist: boolean, permissions: string[] | undefined },
}; };
export type IpcGroupDeleted = { export type IpcGroupDeleted = {
@@ -21,6 +21,8 @@ export class InstancePlugin extends BaseInstancePlugin {
// Once only, don't send permissions for these groups // 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 // This is used for groups created on this instance that only need the controller generated id
skipSendingPermissions = new Set<string>(); skipSendingPermissions = new Set<string>();
// This is used for groups updated / deleted on this instance to stop cycles
skipSendingUpdate = new Set<number>();
// Track known online players so that we only apply assignment updates for them // Track known online players so that we only apply assignment updates for them
onlinePlayers = new Set<string>(); onlinePlayers = new Set<string>();
@@ -80,8 +82,8 @@ export class InstancePlugin extends BaseInstancePlugin {
async handleGroupUpdatedIPC(event: IpcGroupUpdated) { async handleGroupUpdatedIPC(event: IpcGroupUpdated) {
const permissions = new messages.GroupPermissions( const permissions = new messages.GroupPermissions(
event.permissions.is_blacklist, event.permissions.is_blacklist,
event.permissions.permissions, event.permissions.permissions ?? [],
); )
if (event.group_id === undefined) { if (event.group_id === undefined) {
this.skipSendingPermissions.add(event.group_name); this.skipSendingPermissions.add(event.group_name);
@@ -89,6 +91,7 @@ export class InstancePlugin extends BaseInstancePlugin {
new messages.GroupCreateRequest(event.group_name, permissions), new messages.GroupCreateRequest(event.group_name, permissions),
); );
} else { } else {
this.skipSendingUpdate.add(event.group_id);
await this.instance.sendTo("controller", new messages.GroupUpdateRequest( await this.instance.sendTo("controller", new messages.GroupUpdateRequest(
new messages.GroupRecord(event.group_id, event.group_name, permissions), new messages.GroupRecord(event.group_id, event.group_name, permissions),
)); ));
@@ -99,6 +102,7 @@ export class InstancePlugin extends BaseInstancePlugin {
if (event.group_id === undefined) { if (event.group_id === undefined) {
return; return;
} }
this.skipSendingUpdate.add(event.group_id);
await this.instance.sendTo("controller", new messages.GroupDeleteRequest(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[]) { 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; 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) { 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; return;
} }
@@ -142,17 +151,21 @@ export class InstancePlugin extends BaseInstancePlugin {
delete (json as any).permissions; 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) { 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; 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) { 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)
} }
} }
+15 -11
View File
@@ -11,7 +11,7 @@ local ExpGroups = {}
--- @class ExpPermissionGroups.GroupPermissions --- @class ExpPermissionGroups.GroupPermissions
--- @field is_blacklist boolean --- @field is_blacklist boolean
--- @field permissions string[] --- @field permissions string[]?
--- @class ExpPermissionGroups.GroupRecord --- @class ExpPermissionGroups.GroupRecord
--- @field id number --- @field id number
@@ -21,8 +21,8 @@ local ExpGroups = {}
--- @class ExpPermissionGroups.AssignmentRecord --- @class ExpPermissionGroups.AssignmentRecord
--- @field name string --- @field name string
--- @field groupId number --- @field group_id number
--- @field isDeleted boolean --- @field is_deleted boolean
--- @class ExpPermissionGroups.ScriptData --- @class ExpPermissionGroups.ScriptData
--- @field factorio_to_clusterio_id table<number, number?> --- @field factorio_to_clusterio_id table<number, number?>
@@ -73,7 +73,7 @@ end
local function decode_group_permissions(group, permissions) local function decode_group_permissions(group, permissions)
-- Construct a hash map for faster lookup -- Construct a hash map for faster lookup
local action_map = {} 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 action_map[input_action_name] = true
end end
@@ -112,11 +112,15 @@ local function encode_group_permissions(group)
-- Return the whitelist if it is smaller -- Return the whitelist if it is smaller
if blacklist_index > whitelist_index then 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 end
-- Otherwise return the blacklist as it is smaller -- 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 end
--[[ --[[
@@ -167,9 +171,9 @@ end
--- Update an assignment by moving the player to their new group --- Update an assignment by moving the player to their new group
--- @param assignment_record ExpPermissionGroups.AssignmentRecord --- @param assignment_record ExpPermissionGroups.AssignmentRecord
local function update_assignment(assignment_record) 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)) local player = assert(game.get_player(assignment_record.name))
if group then if group then
group.add_player(player) group.add_player(player)
@@ -179,7 +183,7 @@ end
--- Clear an assignment by moving the player to the default group --- Clear an assignment by moving the player to the default group
--- @param assignment_record ExpPermissionGroups.AssignmentRecord --- @param assignment_record ExpPermissionGroups.AssignmentRecord
local function delete_assignment(assignment_record) local function delete_assignment(assignment_record)
assert(assignment_record.isDeleted) assert(assignment_record.is_deleted)
local default_group = get_default_group() local default_group = get_default_group()
local player = assert(game.get_player(assignment_record.name)) 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 local _emit_events = script_data.emit_updates
script_data.emit_updates = false script_data.emit_updates = false
if assignment_record.isDeleted then if assignment_record.is_deleted then
delete_assignment(assignment_record) delete_assignment(assignment_record)
else else
update_assignment(assignment_record) update_assignment(assignment_record)
@@ -378,7 +382,7 @@ local function on_permission_group_added(event)
return return
end end
emit_group_update(event.group) mark_group_dirty(event.group)
end end
--- Handle deletion of permission groups --- Handle deletion of permission groups
+1
View File
@@ -4,6 +4,7 @@
"control.lua" "control.lua"
], ],
"require": [ "require": [
"globals.lua"
], ],
"dependencies": { "dependencies": {
"clusterio": "*" "clusterio": "*"
+20 -5
View File
@@ -1,12 +1,17 @@
import React, { useContext } from "react"; import React, { useContext, useEffect } from "react";
import { Modal, Form, Select } from "antd"; import { Modal, Form, Select } from "antd";
import { ControlContext, useUsers } from "@clusterio/web_ui"; import { ControlContext, useUsers } from "@clusterio/web_ui";
import * as messages from "../../messages"; 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 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 [groups] = plugin.useGroups();
const [users] = useUsers(); const [users] = useUsers();
@@ -16,7 +21,10 @@ export default function AssignmentForm({ open, setOpen, initial }: any) {
function submit(values: any) { function submit(values: any) {
if (initial) { if (initial) {
control.send(new messages.AssignmentUpdateRequest( control.send(new messages.AssignmentUpdateRequest(
new messages.AssignmentRecord(values.name, values.groupId) new messages.AssignmentRecord(
values.name,
values.groupId,
)
)); ));
} else { } else {
control.send(new messages.AssignmentCreateRequest( control.send(new messages.AssignmentCreateRequest(
@@ -28,13 +36,20 @@ export default function AssignmentForm({ open, setOpen, initial }: any) {
setOpen(false); setOpen(false);
} }
useEffect(() => {
if (open) {
form.resetFields();
form.setFieldsValue(initial);
}
}, [open, initial]);
return <Modal return <Modal
title={initial ? "Edit Assignment" : "Create Assignment"} title={initial ? "Edit Assignment" : "Create Assignment"}
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => form.submit()} onOk={() => form.submit()}
> >
<Form form={form} initialValues={initial} onFinish={submit}> <Form form={form} onFinish={submit}>
<Form.Item name="name" label="Player" rules={[{ required: true }]}> <Form.Item name="name" label="Player" rules={[{ required: true }]}>
<Select disabled={Boolean(initial)} showSearch> <Select disabled={Boolean(initial)} showSearch>
{[...users.values()].map(u => ( {[...users.values()].map(u => (
+70 -24
View File
@@ -1,40 +1,86 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState, useRef } from "react";
import { Table, Button, Popconfirm, Space } from "antd"; import { Table, Button, Space, Input, InputRef } from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { EditOutlined, SearchOutlined } from "@ant-design/icons";
import { ControlContext, useAccount } from "@clusterio/web_ui"; import { ControlContext, useAccount } from "@clusterio/web_ui";
import * as messages from "../../messages"; import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages";
import type { WebPlugin } from "..";
import AssignmentForm from "./AssignmentForm"; import AssignmentForm from "./AssignmentForm";
import DeletedConfirm from "./DeleteConfirm";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function AssignmentsTable() { export default function AssignmentsTable() {
const control = useContext(ControlContext); 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 account = useAccount();
const [assignments, synced] = plugin.useAssignments(); const searchInput = useRef<InputRef>(null);
const [groups] = plugin.useGroups();
const [editing, setEditing] = useState<any>(null); const [assignments, assignmentsSynced] = plugin.useAssignments();
const [groups, groupsSynced] = plugin.useGroups();
const [editing, setEditing] = useState<AssignmentRecord | undefined>();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const assignmentsArray = [...assignments.values()];
const groupFilters = [...groups.values()]
.filter(group => assignmentsArray.some(assignment => assignment.groupId === group.id))
.map(group => ({ text: group.name, value: group.id }))
return <> return <>
<Table <Table
columns={[ columns={[
{ {
title: "Player", title: "Player",
dataIndex: "name", dataIndex: "name",
sorter: (a: any, b: any) => strcmp(a.name, b.name), sorter: (a: AssignmentRecord, b: AssignmentRecord) => strcmp(a.name, b.name),
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
onFilter: (value, record: AssignmentRecord) => (
record.name.toLowerCase().includes(String(value).toLowerCase())
),
filterDropdownProps: {
onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100),
},
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
<Input.Search
allowClear
ref={searchInput}
placeholder={"Search username"}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys([e.target.value])}
onSearch={() => confirm({ closeDropdown: false })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
</div>
),
}, },
{ {
title: "Group", title: "Group",
render: (_: any, a: any) => groups.get(a.groupId)?.name ?? a.groupId, width: "40%",
filters: groupFilters,
onFilter: (value, record: AssignmentRecord) => (
record.groupId === value
),
render: (_: any, a: any) => (
groups.get(a.groupId)?.name ?? a.groupId
),
}, },
{ ...(account.hasAnyPermission(
"exp_groups.assignment.update",
"exp_groups.assignment.delete",
) ? [{
title: "Actions", title: "Actions",
render: (_: any, record: any) => ( width: "10%",
render: (_: any, record: AssignmentRecord) => (
<Space> <Space>
{account.hasPermission("exp_groups.assignment.update") && {account.hasPermission("exp_groups.assignment.update") &&
<Button icon={<EditOutlined />} onClick={() => { <Button icon={<EditOutlined />} onClick={() => {
@@ -42,22 +88,22 @@ export default function AssignmentsTable() {
setOpen(true); setOpen(true);
}} /> }} />
} }
{account.hasPermission("exp_groups.assignment.delete") && {account.hasPermission("exp_groups.assignment.delete") && <DeletedConfirm
<Popconfirm onConfirm={() => control.send(new AssignmentDeleteRequest(record.name))}
title="Delete?" />}
onConfirm={() => control.send(new messages.AssignmentDeleteRequest(record.name))}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
</Space> </Space>
), ),
}, }] : []),
]} ]}
dataSource={[...assignments.values()]} dataSource={assignmentsArray}
loading={!synced} loading={!assignmentsSynced || !groupsSynced}
pagination={false}
rowKey={(a) => a.name} rowKey={(a) => a.name}
pagination={{
defaultPageSize: 50,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100", "200"],
showTotal: (total: number) => `${total} Assignments`,
}}
/> />
<AssignmentForm open={open} setOpen={setOpen} initial={editing} /> <AssignmentForm open={open} setOpen={setOpen} initial={editing} />
@@ -0,0 +1,16 @@
import React from "react";
import { Button, Popconfirm } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { TooltipPlacement } from "antd/es/tooltip";
export default function DeletedConfirm(props: { onConfirm: () => void, placement?: TooltipPlacement }) {
return <Popconfirm
title="Delete?"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={props.onConfirm}
placement={props.placement}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
+23 -19
View File
@@ -1,38 +1,42 @@
import React, { useContext } from "react"; import React, { useContext, useEffect } from "react";
import { Modal, Form, Input, Switch } from "antd"; import { Modal, Form, Input, Switch } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ControlContext } from "@clusterio/web_ui"; import { ControlContext } from "@clusterio/web_ui";
import * as messages from "../../messages"; import * as messages from "../../messages";
export default function GroupForm({ open, setOpen, initial }: any) { export default function GroupForm({ open, setOpen }: {
open: boolean,
setOpen: (open: boolean) => void,
}) {
const control = useContext(ControlContext); const control = useContext(ControlContext);
const [form] = Form.useForm();
const navigate = useNavigate(); const navigate = useNavigate();
async function submit(values: any) { const [form] = Form.useForm();
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`);
}
async function submit(values: any) {
const group = await control.send(new messages.GroupCreateRequest(
values.name, new messages.GroupPermissions(Boolean(values.isBlacklist), [])
));
navigate(`/exp_groups/${group.id}/view`);
setOpen(false); setOpen(false);
} }
return <Modal title="Group" open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()}> useEffect(() => {
<Form form={form} initialValues={initial} onFinish={submit}> if (open) form.resetFields();
}, [open]);
return <Modal
title="Create Group"
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
>
<Form form={form} onFinish={submit}>
<Form.Item name="name" label="Name" rules={[{ required: true }]}> <Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="isBlacklist" label="Grant all by default" valuePropName="checked"> <Form.Item name="isBlacklist" label="Grant all by default" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
+32 -28
View File
@@ -1,13 +1,14 @@
import React, { useContext, useEffect, useMemo, useState } from "react"; 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 { Button, Checkbox, Input, Space, Spin, Alert } from "antd";
import { ControlContext, useAccount, useDefaultModPack } from "@clusterio/web_ui"; import { ControlContext, useAccount, useDefaultModPack, PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui";
import { PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui"; import DeletedConfirm from "./DeleteConfirm";
import * as messages from "../../messages"; import * as messages from "../../messages";
import type { WebPlugin } from "..";
const DOMAIN_RULES: Record<string, string[]> = { const DOMAIN_MAPPING = {
"Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"], "Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"],
"Building & Crafting": ["mining", "build", "craft", "deconstruct", "rotate", "entity"], "Building & Crafting": ["mining", "build", "craft", "deconstruct", "rotate", "entity"],
"Inventory": ["inventory", "stack", "cursor", "slot", "quick_bar", "item", "equipment"], "Inventory": ["inventory", "stack", "cursor", "slot", "quick_bar", "item", "equipment"],
@@ -24,7 +25,7 @@ const DOMAIN_RULES: Record<string, string[]> = {
function getDomain(name: string) { function getDomain(name: string) {
const n = name.toLowerCase(); 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; if (keywords.some(k => n.includes(k))) return domain;
} }
@@ -33,7 +34,8 @@ function getDomain(name: string) {
export default function GroupViewPage() { export default function GroupViewPage() {
const control = useContext(ControlContext); 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 account = useAccount();
const { id } = useParams(); const { id } = useParams();
@@ -52,6 +54,7 @@ export default function GroupViewPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const canUpdate = Boolean(account.hasPermission("exp_groups.group.update")); const canUpdate = Boolean(account.hasPermission("exp_groups.group.update"));
const canDelete = Boolean(account.hasPermission("exp_groups.group.delete"));
// fetch defines // fetch defines
useEffect(() => { useEffect(() => {
@@ -59,16 +62,12 @@ export default function GroupViewPage() {
if (definesJson) return; if (definesJson) return;
const assetName = defaultModPack.exportManifest.assets.defines; const assetName = defaultModPack.exportManifest.assets.defines;
(async () => { (async () => {
try { const response = await fetch(`${staticRoot}static/${assetName}`);
const response = await fetch(`${staticRoot}static/${assetName}`); const json = await response.json();
const json = await response.json(); setDefinesJson(json.input_action ?? {});
setDefinesJson(json.input_action ?? {}); })().catch(notifyErrorHandler("Failed to load permissions"));
} catch (err) {
console.error("Failed to load defines", err);
}
})();
}, [defaultModPack]); }, [defaultModPack]);
// initialise permissions // initialise permissions
@@ -94,17 +93,13 @@ export default function GroupViewPage() {
}, [group]); }, [group]);
// derived data // derived data
const allPermissions = useMemo( const allPermissions = useMemo(() => (
() => Object.keys(definesJson ?? {}), Object.keys(definesJson ?? {})
[definesJson] ), [definesJson]);
);
const filtered = useMemo( const filtered = useMemo(() => allPermissions.filter(p =>
() => allPermissions.filter(p => p.toLowerCase().includes(search.toLowerCase())
p.toLowerCase().includes(search.toLowerCase()) ), [allPermissions, search]);
),
[allPermissions, search]
);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
@@ -169,8 +164,16 @@ export default function GroupViewPage() {
</PageLayout>; </PageLayout>;
} }
return <PageLayout nav={[{ name: "exp_groups", path: "/exp_groups" }, { name: group.name }]}> return <PageLayout nav={[{ name: "Permission Groups", path: "/permission_groups" }, { name: group.name }]}>
<PageHeader title={group.name} /> <PageHeader
title={group.name}
extra={<Space wrap>
{canDelete && <DeletedConfirm placement="bottomRight" onConfirm={async () => {
control.send(new messages.GroupDeleteRequest(group.id));
navigate(`/permission_groups`);
}}/>}
</Space>}
/>
{edited && ( {edited && (
<div style={{ <div style={{
@@ -204,7 +207,8 @@ export default function GroupViewPage() {
<Alert <Alert
type="warning" type="warning"
message="Missing export" message="Missing export"
description="An export of the default mod pack is required to see all available permissions." description="An export of the default mod pack is required to modify permissions."
style={{ marginBottom: 16 }}
showIcon showIcon
/> />
)} )}
+11 -10
View File
@@ -2,7 +2,9 @@ import React, { useContext, useState } from "react";
import { Table } from "antd"; import { Table } from "antd";
import { useNavigate } from "react-router-dom"; 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"; import GroupForm from "./GroupForm";
@@ -10,7 +12,7 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base"
export default function GroupsTable() { export default function GroupsTable() {
const control = useContext(ControlContext); 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 navigate = useNavigate();
const [groups, synced] = plugin.useGroups(); const [groups, synced] = plugin.useGroups();
@@ -23,23 +25,22 @@ export default function GroupsTable() {
{ {
title: "Name", title: "Name",
dataIndex: "name", dataIndex: "name",
sorter: (a: any, b: any) => strcmp(a.name, b.name), sorter: (a: any, b: GroupRecord) => strcmp(a.name, b.name),
},
{
title: "Type",
render: (_: any, g: any) => g.permissions.isBlacklist ? "Blacklist" : "Whitelist",
}, },
{ {
title: "Permissions", 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()]} dataSource={[...groups.values()]}
loading={!synced} loading={!synced}
pagination={false}
rowKey={(g) => g.id} rowKey={(g) => g.id}
pagination={false}
onRow={(g) => ({ onRow={(g) => ({
onClick: () => navigate(`/exp_groups/${g.id}/view`), onClick: () => navigate(`/permission_groups/${g.id}/view`),
})} })}
/> />
+57 -33
View File
@@ -1,12 +1,17 @@
import React, { useContext } from "react"; import React, { useContext, useEffect } from "react";
import { Modal, Form, Select, InputNumber, Switch } from "antd"; import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd";
import { ControlContext, useRoles } from "@clusterio/web_ui"; 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 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 [groups] = plugin.useGroups();
const [roles] = useRoles(); const [roles] = useRoles();
@@ -14,18 +19,18 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) {
const [form] = Form.useForm(); const [form] = Form.useForm();
function submit(values: any) { function submit(values: any) {
const record = new messages.RoleMappingRecord(
initial?.id,
new Set(values.roleIds),
values.groupId,
values.priority,
values.enabled,
);
if (initial) { 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 { } else {
control.send(new messages.RoleMappingCreateRequest( control.send(new RoleMappingCreateRequest(
values.roleIds, values.roleIds,
values.groupId, values.groupId,
values.priority, values.priority,
@@ -36,30 +41,39 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) {
setOpen(false); setOpen(false);
} }
useEffect(() => {
if (open) {
form.resetFields();
form.setFieldsValue({
...initial,
roleIds: initial ? [...initial.roleIds] : [],
});
}
}, [open, initial]);
return <Modal return <Modal
title={initial ? "Edit Role Mapping" : "Create Role Mapping"} title={initial ? "Edit Role Mapping" : "Create Role Mapping"}
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => form.submit()} onOk={() => form.submit()}
> >
<Form <Alert
form={form} type="info"
initialValues={{ showIcon
...initial, message="How role mappings work"
roleIds: initial ? [...initial.roleIds] : [], style={{ marginBottom: 16 }}
}} description={
onFinish={submit} <ul style={{ paddingLeft: 16, margin: 0 }}>
> <li>Manual assignments always take priority.</li>
<Form.Item name="roleIds" label="Roles" rules={[{ required: true }]}> <li>Mappings are checked in priority order (highest first).</li>
<Select mode="multiple"> <li>A mapping applies only if the user has <b>all listed roles</b>.</li>
{[...roles.values()].map(r => ( <li>The first matching mapping assigns the group.</li>
<Select.Option key={r.id} value={r.id}> <li>If none match, the player is given all permissions.</li>
{r.name} </ul>
</Select.Option> }
))} />
</Select>
</Form.Item>
<Form form={form} onFinish={submit}>
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}> <Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
<Select> <Select>
{[...groups.values()].map(g => ( {[...groups.values()].map(g => (
@@ -70,7 +84,17 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="priority" label="Priority" initialValue={0}> <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="priority" label="Priority" initialValue={100}>
<InputNumber style={{ width: "100%" }} /> <InputNumber style={{ width: "100%" }} />
</Form.Item> </Form.Item>
+70 -37
View File
@@ -1,72 +1,105 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { Table, Button, Popconfirm, Space, Tag } from "antd"; import { Table, Button, Space, Tag } from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { EditOutlined } from "@ant-design/icons";
import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui"; 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 RoleMappingForm from "./RoleMappingForm";
import DeletedConfirm from "./DeleteConfirm";
export default function RoleMappingsTable() { export default function RoleMappingsTable() {
const control = useContext(ControlContext); 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 account = useAccount();
const [roleMappings, synced] = plugin.useRoleMappings(); const [roleMappings, roleMappingsSynced] = plugin.useRoleMappings();
const [groups] = plugin.useGroups(); const [groups, groupsSynced] = plugin.useGroups();
const [roles] = useRoles(); const [roles, rolesSynced] = useRoles();
const [editing, setEditing] = useState<any>(null); const [editing, setEditing] = useState<RoleMappingRecord | undefined>();
const [open, setOpen] = useState(false); 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 <> return <>
<Table <Table
columns={[ columns={[
{ {
title: "Priority", title: "Priority",
dataIndex: "priority", dataIndex: "priority",
width: "10%",
}, },
{ {
title: "Roles", title: "Roles",
render: (_: any, m: any) => [...m.roleIds].map((id: number) => { filters: roleFilters,
const role = roles.get(id); onFilter: (value, record: RoleMappingRecord) => (
return <Tag key={id}>{role?.name ?? id}</Tag>; record.roleIds.has(value as number)
}), ),
render: (_: any, record: RoleMappingRecord) => (
[...record.roleIds].map(id => <Tag key={id}>{roles.get(id)?.name ?? id}</Tag>)
),
}, },
{ {
title: "Group", 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", title: "Enabled",
render: (_: any, m: any) => m.enabled ? "Yes" : "No", width: "10%",
}, filters: [
{ { text: "Enabled", value: true },
title: "Actions", { text: "Disabled", value: false },
render: (_: any, record: any) => ( ],
<Space> onFilter: (value, record: RoleMappingRecord) => (
{account.hasPermission("exp_groups.role_mapping.update") && record.enabled === value
<Button icon={<EditOutlined />} onClick={() => { ),
setEditing(record); render: (_: any, record: RoleMappingRecord) => (
setOpen(true); record.enabled ? "Yes" : "No"
}} />
}
{account.hasPermission("exp_groups.role_mapping.delete") &&
<Popconfirm
title="Delete?"
onConfirm={() => control.send(new messages.RoleMappingDeleteRequest(record.id))}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
</Space>
), ),
}, },
...(account.hasAnyPermission(
"exp_groups.role_mapping.update",
"exp_groups.role_mapping.delete",
) ? [{
title: "Actions",
width: "10%",
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") && <DeletedConfirm
onConfirm={() => control.send(new RoleMappingDeleteRequest(record.id))}
/>}
</Space>
),
}] : []),
]} ]}
dataSource={[...roleMappings.values()].sort((a, b) => b.priority - a.priority)} dataSource={roleMappingArray.sort((a, b) => b.priority - a.priority)}
loading={!synced} loading={!roleMappingsSynced || !groupsSynced || !rolesSynced}
pagination={false}
rowKey={(m) => m.id} rowKey={(m) => m.id}
pagination={false}
/> />
<RoleMappingForm open={open} setOpen={setOpen} initial={editing} /> <RoleMappingForm open={open} setOpen={setOpen} initial={editing} />
+4 -4
View File
@@ -21,7 +21,7 @@ function ExpGroupsPage() {
const [assignmentOpen, setAssignmentOpen] = useState(false); const [assignmentOpen, setAssignmentOpen] = useState(false);
const [roleMappingOpen, setRoleMappingOpen] = useState(false); const [roleMappingOpen, setRoleMappingOpen] = useState(false);
return <PageLayout nav={[{ name: "exp_groups" }]}> return <PageLayout nav={[{ name: "Permission Groups" }]}>
<PageHeader title="Permission Groups" /> <PageHeader title="Permission Groups" />
{account.hasPermission("exp_groups.group.list") && <> {account.hasPermission("exp_groups.group.list") && <>
@@ -73,7 +73,7 @@ export class WebPlugin extends BaseWebPlugin {
async init() { async init() {
this.pages = [ this.pages = [
{ {
path: "/exp_groups", path: "/permission_groups",
sidebarName: "Permission Groups", sidebarName: "Permission Groups",
permission: (account => account.hasAnyPermission( permission: (account => account.hasAnyPermission(
"exp_groups.group.list", "exp_groups.group.list",
@@ -83,8 +83,8 @@ export class WebPlugin extends BaseWebPlugin {
content: <ExpGroupsPage />, content: <ExpGroupsPage />,
}, },
{ {
path: "/exp_groups/:id/view", path: "/permission_groups/:id/view",
sidebarPath: "/exp_groups", sidebarPath: "/permission_groups",
permission: "exp_groups.group.get", permission: "exp_groups.group.get",
content: <GroupViewPage />, content: <GroupViewPage />,
}, },