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
+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>
+32 -28
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(() => {
@@ -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<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} />