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;
}
const user = this.controller.users.get(playerName);
const user = this.controller.users.getByName(playerName);
const userRoles = user?.roleIds ?? new Set<number>();
// 2) Role mappings
+7 -2
View File
@@ -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",
]
};
+23 -10
View File
@@ -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<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
onlinePlayers = new Set<string>();
@@ -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)
}
}
+15 -11
View File
@@ -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<number, number?>
@@ -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
+1
View File
@@ -4,6 +4,7 @@
"control.lua"
],
"require": [
"globals.lua"
],
"dependencies": {
"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 { 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 <Modal
title={initial ? "Edit Assignment" : "Create Assignment"}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
>
<Form form={form} initialValues={initial} onFinish={submit}>
<Form form={form} onFinish={submit}>
<Form.Item name="name" label="Player" rules={[{ required: true }]}>
<Select disabled={Boolean(initial)} showSearch>
{[...users.values()].map(u => (
+70 -24
View File
@@ -1,40 +1,86 @@
import React, { useContext, useState } from "react";
import { Table, Button, Popconfirm, Space } from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
import React, { useContext, useState, useRef } from "react";
import { Table, Button, Space, Input, InputRef } from "antd";
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
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 DeletedConfirm from "./DeleteConfirm";
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 plugin = control.plugins.get("exp_groups") as WebPlugin;
const account = useAccount();
const [assignments, synced] = plugin.useAssignments();
const [groups] = plugin.useGroups();
const searchInput = useRef<InputRef>(null);
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 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 <>
<Table
columns={[
{
title: "Player",
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",
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",
render: (_: any, record: any) => (
width: "10%",
render: (_: any, record: AssignmentRecord) => (
<Space>
{account.hasPermission("exp_groups.assignment.update") &&
<Button icon={<EditOutlined />} onClick={() => {
@@ -42,22 +88,22 @@ export default function AssignmentsTable() {
setOpen(true);
}} />
}
{account.hasPermission("exp_groups.assignment.delete") &&
<Popconfirm
title="Delete?"
onConfirm={() => control.send(new messages.AssignmentDeleteRequest(record.name))}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
{account.hasPermission("exp_groups.assignment.delete") && <DeletedConfirm
onConfirm={() => control.send(new AssignmentDeleteRequest(record.name))}
/>}
</Space>
),
},
}] : []),
]}
dataSource={[...assignments.values()]}
loading={!synced}
pagination={false}
dataSource={assignmentsArray}
loading={!assignmentsSynced || !groupsSynced}
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} />
@@ -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 { useNavigate } from "react-router-dom";
import { ControlContext } from "@clusterio/web_ui";
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 [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`);
}
const [form] = Form.useForm();
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);
}
return <Modal title="Group" open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()}>
<Form form={form} initialValues={initial} onFinish={submit}>
useEffect(() => {
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 }]}>
<Input />
</Form.Item>
<Form.Item name="isBlacklist" label="Grant all by default" valuePropName="checked">
<Switch />
</Form.Item>
+31 -27
View File
@@ -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<string, string[]> = {
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<string, string[]> = {
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(() => {
@@ -61,14 +64,10 @@ export default function GroupViewPage() {
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<string, string[]>();
@@ -169,8 +164,16 @@ export default function GroupViewPage() {
</PageLayout>;
}
return <PageLayout nav={[{ name: "exp_groups", path: "/exp_groups" }, { name: group.name }]}>
<PageHeader title={group.name} />
return <PageLayout nav={[{ name: "Permission Groups", path: "/permission_groups" }, { name: 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 && (
<div style={{
@@ -204,7 +207,8 @@ export default function GroupViewPage() {
<Alert
type="warning"
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
/>
)}
+11 -10
View File
@@ -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`),
})}
/>
+57 -33
View File
@@ -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 <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>
<Alert
type="info"
showIcon
message="How role mappings work"
style={{ marginBottom: 16 }}
description={
<ul style={{ paddingLeft: 16, margin: 0 }}>
<li>Manual assignments always take priority.</li>
<li>Mappings are checked in priority order (highest first).</li>
<li>A mapping applies only if the user has <b>all listed roles</b>.</li>
<li>The first matching mapping assigns the group.</li>
<li>If none match, the player is given all permissions.</li>
</ul>
}
/>
<Form form={form} onFinish={submit}>
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
<Select>
{[...groups.values()].map(g => (
@@ -70,7 +84,17 @@ export default function RoleMappingForm({ open, setOpen, initial }: any) {
</Select>
</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%" }} />
</Form.Item>
+70 -37
View File
@@ -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<any>(null);
const [editing, setEditing] = useState<RoleMappingRecord | undefined>();
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 <>
<Table
columns={[
{
title: "Priority",
dataIndex: "priority",
width: "10%",
},
{
title: "Roles",
render: (_: any, m: any) => [...m.roleIds].map((id: number) => {
const role = roles.get(id);
return <Tag key={id}>{role?.name ?? id}</Tag>;
}),
filters: roleFilters,
onFilter: (value, record: RoleMappingRecord) => (
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",
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) => (
<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>
width: "10%",
filters: [
{ text: "Enabled", value: true },
{ text: "Disabled", value: false },
],
onFilter: (value, record: RoleMappingRecord) => (
record.enabled === value
),
render: (_: any, record: RoleMappingRecord) => (
record.enabled ? "Yes" : "No"
),
},
...(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)}
loading={!synced}
pagination={false}
dataSource={roleMappingArray.sort((a, b) => b.priority - a.priority)}
loading={!roleMappingsSynced || !groupsSynced || !rolesSynced}
rowKey={(m) => m.id}
pagination={false}
/>
<RoleMappingForm open={open} setOpen={setOpen} initial={editing} />
+4 -4
View File
@@ -21,7 +21,7 @@ function ExpGroupsPage() {
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [roleMappingOpen, setRoleMappingOpen] = useState(false);
return <PageLayout nav={[{ name: "exp_groups" }]}>
return <PageLayout nav={[{ name: "Permission Groups" }]}>
<PageHeader title="Permission Groups" />
{account.hasPermission("exp_groups.group.list") && <>
@@ -73,7 +73,7 @@ export class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{
path: "/exp_groups",
path: "/permission_groups",
sidebarName: "Permission Groups",
permission: (account => account.hasAnyPermission(
"exp_groups.group.list",
@@ -83,8 +83,8 @@ export class WebPlugin extends BaseWebPlugin {
content: <ExpGroupsPage />,
},
{
path: "/exp_groups/:id/view",
sidebarPath: "/exp_groups",
path: "/permission_groups/:id/view",
sidebarPath: "/permission_groups",
permission: "exp_groups.group.get",
content: <GroupViewPage />,
},