mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-07-26 18:36:23 +09:00
Merge pull request #419 from Cooldude2606/plugin/groups
Add plugin for permission group syncing
This commit is contained in:
@@ -15,7 +15,6 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: "pnpm"
|
|
||||||
- name: Install FMTK
|
- name: Install FMTK
|
||||||
run: |
|
run: |
|
||||||
pnpm install factoriomod-debug
|
pnpm install factoriomod-debug
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
|
|||||||
+410
-258
@@ -1,282 +1,434 @@
|
|||||||
|
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
import * as messages from "./messages";
|
||||||
|
import * as path from "node:path";
|
||||||
import {
|
|
||||||
PermissionStrings, PermissionStringsUpdate,
|
|
||||||
PermissionGroup, PermissionGroupUpdate,
|
|
||||||
InstancePermissionGroups,
|
|
||||||
PermissionInstanceId,
|
|
||||||
PermissionGroupEditEvent,
|
|
||||||
} from "./messages";
|
|
||||||
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs-extra";
|
|
||||||
|
|
||||||
export class ControllerPlugin extends BaseControllerPlugin {
|
export class ControllerPlugin extends BaseControllerPlugin {
|
||||||
static permissionGroupsPath = "exp_groups.json";
|
groups!: lib.SubscribableDatastore<messages.GroupRecord>;
|
||||||
static userGroupsPath = "exp_user_groups.json";
|
roleMappings!: lib.SubscribableDatastore<messages.RoleMappingRecord>;
|
||||||
|
manualAssignments!: lib.SubscribableDatastore<messages.AssignmentRecord>;
|
||||||
|
resolvedAssignments!: lib.SubscribableDatastore<messages.AssignmentRecord>;
|
||||||
|
|
||||||
userToGroup: Map<lib.User["id"], PermissionGroup> = new Map(); // TODO this needs to be per instance
|
async init() {
|
||||||
permissionStrings!: Map<PermissionStrings["id"], PermissionStrings>;
|
const databaseDirectory = this.controller.config.get("controller.database_directory");
|
||||||
permissionGroups!: Map<InstancePermissionGroups["id"], InstancePermissionGroups>;
|
|
||||||
|
|
||||||
async init() {
|
this.groups = new lib.SubscribableDatastore(
|
||||||
this.controller.handle(PermissionStringsUpdate, this.handlePermissionStringsUpdate.bind(this));
|
...await new lib.JsonIdDatastoreProvider(
|
||||||
this.controller.handle(PermissionGroupUpdate, this.handlePermissionGroupUpdate.bind(this));
|
path.join(databaseDirectory, "exp_groups", "groups.json"),
|
||||||
this.controller.handle(PermissionGroupEditEvent, this.handlePermissionGroupEditEvent.bind(this));
|
messages.GroupRecord.fromJSON.bind(messages.GroupRecord),
|
||||||
this.controller.subscriptions.handle(PermissionStringsUpdate, this.handlePermissionStringsSubscription.bind(this));
|
).bootstrap()
|
||||||
this.controller.subscriptions.handle(PermissionGroupUpdate, this.handlePermissionGroupSubscription.bind(this));
|
);
|
||||||
this.controller.subscriptions.handle(PermissionGroupEditEvent);
|
|
||||||
this.permissionStrings = new Map([["Global", new PermissionStrings("Global", new Set())]]);
|
|
||||||
this.permissionGroups = new Map([["Global", new InstancePermissionGroups("Global")]]);
|
|
||||||
await this.loadData();
|
|
||||||
|
|
||||||
// Add the default group if missing and add any missing cluster roles
|
this.roleMappings = new lib.SubscribableDatastore(
|
||||||
const clusterRoles = [...this.controller.userManager.roles.values()]
|
...await new lib.JsonIdDatastoreProvider(
|
||||||
for (const instanceGroups of this.permissionGroups.values()) {
|
path.join(databaseDirectory, "exp_groups", "role_mappings.json"),
|
||||||
const groups = instanceGroups.groups;
|
messages.RoleMappingRecord.fromJSON.bind(messages.RoleMappingRecord),
|
||||||
const instanceRoles = [...groups.values()].flatMap(group => [...group.roleIds.values()]);
|
).bootstrap()
|
||||||
const missingRoles = clusterRoles.filter(role => instanceRoles.includes(role.id));
|
);
|
||||||
const defaultGroup = groups.get("Default");
|
|
||||||
if (defaultGroup) {
|
|
||||||
for (const role of missingRoles) {
|
|
||||||
defaultGroup.roleIds.add(role.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
groups.set("Default", new PermissionGroup(
|
|
||||||
instanceGroups.instanceId,
|
|
||||||
"Default",
|
|
||||||
groups.size,
|
|
||||||
new Set(missingRoles.map(role => role.id))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
this.manualAssignments = new lib.SubscribableDatastore(
|
||||||
if (field === "exp_groups.allow_role_inconsistency") {
|
...await new lib.JsonIdDatastoreProvider(
|
||||||
// Do something with this.userToGroup
|
path.join(databaseDirectory, "exp_groups", "assignments.json"),
|
||||||
}
|
messages.AssignmentRecord.fromJSON.bind(messages.AssignmentRecord),
|
||||||
}
|
).bootstrap()
|
||||||
|
);
|
||||||
|
|
||||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
|
this.resolvedAssignments = new lib.SubscribableDatastore();
|
||||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
|
||||||
if (field === "exp_groups.sync_permission_groups") {
|
|
||||||
const updates = []
|
|
||||||
const now = Date.now();
|
|
||||||
if (curr) {
|
|
||||||
// Global sync enabled, we dont need the instance config
|
|
||||||
const instanceGroups = this.permissionGroups.get(instance.id);
|
|
||||||
if (instanceGroups) {
|
|
||||||
this.permissionGroups.delete(instance.id);
|
|
||||||
for (const group of instanceGroups.groups.values()) {
|
|
||||||
group.updatedAtMs = now;
|
|
||||||
group.isDeleted = true;
|
|
||||||
updates.push(group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Global sync disabled, make a copy of the global config as a base
|
|
||||||
const global = this.permissionGroups.get("Global")!;
|
|
||||||
const oldInstanceGroups = this.permissionGroups.get(instance.id);
|
|
||||||
const instanceGroups = new InstancePermissionGroups(
|
|
||||||
instance.id, new Map([...global.groups.values()].map(group => [group.name, group.copy(instance.id)]))
|
|
||||||
)
|
|
||||||
this.permissionGroups.set(instance.id, instanceGroups);
|
|
||||||
for (const group of instanceGroups.groups.values()) {
|
|
||||||
group.updatedAtMs = now;
|
|
||||||
updates.push(group);
|
|
||||||
}
|
|
||||||
// If it has an old config (unexpected) then deal with it
|
|
||||||
if (oldInstanceGroups) {
|
|
||||||
for (const group of oldInstanceGroups.groups.values()) {
|
|
||||||
if (!instanceGroups.groups.has(group.name)) {
|
|
||||||
group.updatedAtMs = now;
|
|
||||||
group.isDeleted = true;
|
|
||||||
updates.push(group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Send the updates to all instances and controls
|
|
||||||
if (updates.length) {
|
|
||||||
this.controller.subscriptions.broadcast(new PermissionGroupUpdate(updates));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPermissionGroups() {
|
this.controller.subscriptions.handle(messages.GroupUpdatedEvent, this.handleGroupSubscription.bind(this));
|
||||||
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath);
|
this.controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, this.handleRoleMappingSubscription.bind(this));
|
||||||
this.logger.verbose(`Loading ${file}`);
|
this.controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, this.handleManualAssignmentSubscription.bind(this));
|
||||||
try {
|
this.controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentSubscription.bind(this));
|
||||||
const content = await fs.readFile(file, { encoding: "utf8" });
|
|
||||||
for (const groupRaw of JSON.parse(content)) {
|
|
||||||
const group = PermissionGroup.fromJSON(groupRaw);
|
|
||||||
const instanceGroups = this.permissionGroups.get(group.instanceId);
|
|
||||||
if (instanceGroups) {
|
|
||||||
instanceGroups.groups.set(group.name, group);
|
|
||||||
} else {
|
|
||||||
this.permissionGroups.set(group.instanceId,
|
|
||||||
new InstancePermissionGroups(group.instanceId, new Map([[group.name, group]]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (err: any) {
|
this.groups.on("update", this.groupsUpdated.bind(this));
|
||||||
if (err.code === "ENOENT") {
|
this.roleMappings.on("update", this.roleMappingsUpdated.bind(this));
|
||||||
this.logger.verbose("Creating new permission group database");
|
this.manualAssignments.on("update", this.manualAssignmentsUpdated.bind(this));
|
||||||
return;
|
this.resolvedAssignments.on("update", this.resolvedAssignmentsUpdated.bind(this));
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async savePermissionGroups() {
|
this.controller.handle(messages.GroupCreateRequest, this.handleGroupCreateRequest.bind(this));
|
||||||
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath);
|
this.controller.handle(messages.GroupUpdateRequest, this.handleGroupUpdateRequest.bind(this));
|
||||||
this.logger.verbose(`Writing ${file}`);
|
this.controller.handle(messages.GroupDeleteRequest, this.handleGroupDeleteRequest.bind(this));
|
||||||
await lib.safeOutputFile(file, JSON.stringify(
|
this.controller.handle(messages.GroupGetRequest, this.handleGroupGetRequest.bind(this));
|
||||||
[...this.permissionGroups.values()].flatMap(instanceGroups => [...instanceGroups.groups.values()])
|
this.controller.handle(messages.GroupListRequest, this.handleGroupListRequest.bind(this));
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadUserGroups() {
|
this.controller.handle(messages.AssignmentCreateRequest, this.handleAssignmentCreateRequest.bind(this));
|
||||||
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return;
|
this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this));
|
||||||
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath);
|
this.controller.handle(messages.AssignmentDeleteRequest, this.handleAssignmentDeleteRequest.bind(this));
|
||||||
this.logger.verbose(`Loading ${file}`);
|
this.controller.handle(messages.AssignmentGetRequest, this.handleAssignmentGetRequest.bind(this));
|
||||||
try {
|
this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this));
|
||||||
const content = await fs.readFile(file, { encoding: "utf8" });
|
|
||||||
this.userToGroup = new Map(JSON.parse(content));
|
|
||||||
|
|
||||||
} catch (err: any) {
|
this.controller.handle(messages.RoleMappingCreateRequest, this.handleRoleMappingCreateRequest.bind(this));
|
||||||
if (err.code === "ENOENT") {
|
this.controller.handle(messages.RoleMappingUpdateRequest, this.handleRoleMappingUpdateRequest.bind(this));
|
||||||
this.logger.verbose("Creating new user group database");
|
this.controller.handle(messages.RoleMappingDeleteRequest, this.handleRoleMappingDeleteRequest.bind(this));
|
||||||
return;
|
this.controller.handle(messages.RoleMappingGetRequest, this.handleRoleMappingGetRequest.bind(this));
|
||||||
}
|
this.controller.handle(messages.RoleMappingListRequest, this.handleRoleMappingListRequest.bind(this));
|
||||||
throw err;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveUserGroups() {
|
async onShutdown() {
|
||||||
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return;
|
await Promise.all([
|
||||||
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath);
|
this.groups.save(),
|
||||||
this.logger.verbose(`Writing ${file}`);
|
this.manualAssignments.save(),
|
||||||
await lib.safeOutputFile(file, JSON.stringify([...this.permissionGroups.entries()]));
|
this.roleMappings.save(),
|
||||||
}
|
])
|
||||||
|
}
|
||||||
|
|
||||||
async loadData() {
|
/*
|
||||||
await Promise.all([
|
Subscriptions
|
||||||
this.loadPermissionGroups(),
|
*/
|
||||||
this.loadUserGroups(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async onSaveData() {
|
async groupsUpdated(groups: messages.GroupRecord[]) {
|
||||||
await Promise.all([
|
this.controller.subscriptions.broadcast(new messages.GroupUpdatedEvent(groups));
|
||||||
this.savePermissionGroups(),
|
|
||||||
this.saveUserGroups(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
addPermisisonGroup(instanceId: PermissionInstanceId, name: string, permissions = new Set<string>(), silent = false) {
|
// We need to do extra work if the group was deleted
|
||||||
const instanceGroups = this.permissionGroups.get(instanceId);
|
const deletedGroupIds = groups.filter(g => g.isDeleted).map(g => g.id);
|
||||||
if (!instanceGroups) {
|
if (!deletedGroupIds.length) {
|
||||||
throw new Error("Instance ID does not exist");
|
return;
|
||||||
}
|
}
|
||||||
if (instanceGroups.groups.has(name)) {
|
|
||||||
return instanceGroups.groups.get(name)!;
|
|
||||||
}
|
|
||||||
for (const group of instanceGroups.groups.values()) {
|
|
||||||
group.order += 1;
|
|
||||||
}
|
|
||||||
const group = new PermissionGroup(instanceId, name, 0, new Set(), permissions, Date.now(), false);
|
|
||||||
instanceGroups.groups.set(group.id, group);
|
|
||||||
if (!silent) {
|
|
||||||
this.controller.subscriptions.broadcast(new PermissionGroupUpdate([group]));
|
|
||||||
}
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
removePermissionGroup(instanceId: PermissionInstanceId, name: string, silent = false) {
|
// Cascade the delete down to affected role mappings
|
||||||
const instanceGroups = this.permissionGroups.get(instanceId);
|
const mappingsToDelete = [];
|
||||||
if (!instanceGroups) {
|
for (const mapping of this.roleMappings.values()) {
|
||||||
throw new Error("Instance ID does not exist");
|
if (deletedGroupIds.includes(mapping.groupId)) {
|
||||||
}
|
mappingsToDelete.push(mapping);
|
||||||
const group = instanceGroups.groups.get(name)
|
}
|
||||||
if (!group) {
|
}
|
||||||
return null;
|
if (mappingsToDelete.length) {
|
||||||
}
|
this.roleMappings.deleteMany(mappingsToDelete);
|
||||||
for (const nextGroup of instanceGroups.groups.values()) {
|
}
|
||||||
if (nextGroup.order > group.order) {
|
|
||||||
nextGroup.order -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
instanceGroups.groups.delete(group.id);
|
|
||||||
group.updatedAtMs = Date.now();
|
|
||||||
group.isDeleted = true;
|
|
||||||
if (!silent) {
|
|
||||||
this.controller.subscriptions.broadcast(new PermissionGroupUpdate([group]));
|
|
||||||
}
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePermissionGroupEditEvent(event: PermissionGroupEditEvent) {
|
// Cascade the delete down to affected manual assignments
|
||||||
// TODO
|
const affectedPlayers = new Set<string>();
|
||||||
}
|
const assignmentsToDelete = [];
|
||||||
|
for (const assignment of this.manualAssignments.values()) {
|
||||||
|
if (deletedGroupIds.includes(assignment.groupId)) {
|
||||||
|
assignmentsToDelete.push(assignment);
|
||||||
|
affectedPlayers.add(assignment.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assignmentsToDelete.length) {
|
||||||
|
this.manualAssignments.deleteMany(assignmentsToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
async handlePermissionStringsUpdate(event: PermissionStringsUpdate) {
|
// Find all the affected players who were assigned to this group
|
||||||
for (const update of event.updates) {
|
for (const resolved of this.resolvedAssignments.values()) {
|
||||||
const global = this.permissionStrings.get("Global")!
|
if (deletedGroupIds.includes(resolved.groupId)) {
|
||||||
this.permissionStrings.set(update.instanceId as number, update)
|
affectedPlayers.add(resolved.name);
|
||||||
global.updatedAtMs = Math.max(global.updatedAtMs, update.updatedAtMs)
|
}
|
||||||
for (const permission of update.permissions) {
|
}
|
||||||
global.permissions.add(permission)
|
if (affectedPlayers.size) {
|
||||||
}
|
this.resolvedAssignments.setMany(await this.computeResolvedAssignments([...affectedPlayers]));
|
||||||
// TODO maybe check if changes have happened rather than always pushing updates
|
}
|
||||||
this.controller.subscriptions.broadcast(new PermissionStringsUpdate([global, update]))
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePermissionGroupUpdate(event: PermissionGroupUpdate) {
|
async handleGroupSubscription(request: lib.SubscriptionRequest) {
|
||||||
const updates = [];
|
const groups = [...this.groups.values()]
|
||||||
for (const group of event.updates) {
|
.filter(group => group.updatedAtMs > request.lastRequestTimeMs);
|
||||||
const groups = this.permissionGroups.get(group.instanceId);
|
return groups.length ? new messages.GroupUpdatedEvent(groups) : null;
|
||||||
if (!groups) continue;
|
}
|
||||||
const existingGroup = groups.groups.get(group.id);
|
|
||||||
let update
|
|
||||||
if (!existingGroup) {
|
|
||||||
update = this.addPermisisonGroup(group.instanceId, group.name, group.permissions, true);
|
|
||||||
} else if (group.isDeleted) {
|
|
||||||
update = this.removePermissionGroup(group.instanceId, group.name, true);
|
|
||||||
} else {
|
|
||||||
existingGroup.permissions = group.permissions;
|
|
||||||
existingGroup.updatedAtMs = Date.now();
|
|
||||||
update = existingGroup;
|
|
||||||
}
|
|
||||||
if (update) updates.push(update);
|
|
||||||
}
|
|
||||||
this.controller.subscriptions.broadcast(new PermissionGroupUpdate(updates));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePermissionStringsSubscription(request: lib.SubscriptionRequest, src: lib.Address) {
|
async roleMappingsUpdated(roleMappings: messages.RoleMappingRecord[]) {
|
||||||
const updates = [ ...this.permissionStrings.values() ]
|
this.controller.subscriptions.broadcast(new messages.RoleMappingUpdatedEvent(roleMappings));
|
||||||
.filter(
|
|
||||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
|
||||||
)
|
|
||||||
return updates.length ? new PermissionStringsUpdate(updates) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePermissionGroupSubscription(request: lib.SubscriptionRequest, src: lib.Address) {
|
// Mappings pointing to a deleted group have already been handled
|
||||||
const updates = [ ...this.permissionGroups.values() ]
|
// But if any are active, then we still must recompute all assignments
|
||||||
.flatMap(instanceGroups => [...instanceGroups.groups.values()])
|
let hasActiveGroup = false;
|
||||||
.filter(
|
for (const roleMapping of roleMappings) {
|
||||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
const group = this.groups.get(roleMapping.groupId);
|
||||||
)
|
if (group && !group.isDeleted) {
|
||||||
if (src.type === lib.Address.instance) {
|
hasActiveGroup = true;
|
||||||
const instanceUpdates = updates.filter(group => group.instanceId === src.id || group.instanceId === "Global");
|
break;
|
||||||
this.logger.info(JSON.stringify(updates))
|
}
|
||||||
this.logger.info(JSON.stringify(instanceUpdates))
|
}
|
||||||
return instanceUpdates.length ? new PermissionGroupUpdate(instanceUpdates) : null;
|
if (!hasActiveGroup) {
|
||||||
}
|
return;
|
||||||
return updates.length ? new PermissionGroupUpdate(updates) : null;
|
}
|
||||||
}
|
|
||||||
|
// Affected players are those without manual assignments
|
||||||
|
const affectedPlayers = [];
|
||||||
|
for (const resolved of this.resolvedAssignments.values()) {
|
||||||
|
if (!this.manualAssignments.has(resolved.name)) {
|
||||||
|
affectedPlayers.push(resolved.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (affectedPlayers.length) {
|
||||||
|
this.resolvedAssignments.setMany(await this.computeResolvedAssignments(affectedPlayers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRoleMappingSubscription(request: lib.SubscriptionRequest) {
|
||||||
|
const mappings = [...this.roleMappings.values()]
|
||||||
|
.filter(mapping => mapping.updatedAtMs > request.lastRequestTimeMs);
|
||||||
|
return mappings.length ? new messages.RoleMappingUpdatedEvent(mappings) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async manualAssignmentsUpdated(assignments: messages.AssignmentRecord[]) {
|
||||||
|
this.controller.subscriptions.broadcast(new messages.ManualAssignmentUpdatedEvent(assignments));
|
||||||
|
|
||||||
|
// Assignments pointing to a deleted group have already been handled
|
||||||
|
const affectedPlayers: string[] = [];
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const group = this.groups.get(assignment.groupId);
|
||||||
|
if (!group || group.isDeleted) continue;
|
||||||
|
affectedPlayers.push(assignment.name);
|
||||||
|
}
|
||||||
|
if (affectedPlayers.length) {
|
||||||
|
this.resolvedAssignments.setMany(await this.computeResolvedAssignments(affectedPlayers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleManualAssignmentSubscription(request: lib.SubscriptionRequest) {
|
||||||
|
const assignments = [...this.resolvedAssignments.values()]
|
||||||
|
.filter(a => a.updatedAtMs > request.lastRequestTimeMs);
|
||||||
|
return assignments.length ? new messages.ManualAssignmentUpdatedEvent(assignments) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedAssignmentsUpdated(assignments: messages.AssignmentRecord[]) {
|
||||||
|
this.controller.subscriptions.broadcast(
|
||||||
|
new messages.ResolvedAssignmentUpdatedEvent(assignments),
|
||||||
|
assignments.map(assignment => assignment.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleResolvedAssignmentSubscription(request: lib.SubscriptionRequest) {
|
||||||
|
// Check for any missing assignments to be computed on demand
|
||||||
|
const filters = Array.isArray(request.filters) ? request.filters : [request.filters!];
|
||||||
|
if (request.filters && filters.length) {
|
||||||
|
const missing = filters.filter(name => !this.resolvedAssignments.has(name));
|
||||||
|
if (missing.length) {
|
||||||
|
this.resolvedAssignments.setMany(await this.computeResolvedAssignments(missing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter the assignments
|
||||||
|
const assignments = (filters.length
|
||||||
|
? filters.map(name => this.resolvedAssignments.get(name)).filter(Boolean)
|
||||||
|
: [...this.resolvedAssignments.values()]
|
||||||
|
).filter(a => a.updatedAtMs > request.lastRequestTimeMs);
|
||||||
|
|
||||||
|
return assignments.length ? new messages.ResolvedAssignmentUpdatedEvent(assignments) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Groups
|
||||||
|
*/
|
||||||
|
|
||||||
|
async handleGroupListRequest() {
|
||||||
|
return [...this.groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGroupCreateRequest(request: messages.GroupCreateRequest) {
|
||||||
|
if ([...this.groups.values()].some(g => g.name === request.name)) {
|
||||||
|
throw new lib.RequestError(`Group with name '${request.name}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Math.random() * 2**31 | 0;
|
||||||
|
while (this.groups.has(id)) {
|
||||||
|
id = Math.random() * 2**31 | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = new messages.GroupRecord(id, request.name, request.permissions);
|
||||||
|
this.groups.set(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) {
|
||||||
|
const group = request.group;
|
||||||
|
if (group.id === undefined || !this.groups.has(group.id)) {
|
||||||
|
throw new lib.RequestError(`Group with ID ${group.id} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.groups.set(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGroupDeleteRequest(request: messages.GroupDeleteRequest) {
|
||||||
|
const { groupId } = request;
|
||||||
|
|
||||||
|
const group = this.groups.getMutable(groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new lib.RequestError(`Group with ID ${groupId} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.groups.delete(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGroupGetRequest(request: messages.GroupGetRequest) {
|
||||||
|
const group = this.groups.get(request.groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new lib.RequestError(`Group with ID ${request.groupId} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Groups
|
||||||
|
*/
|
||||||
|
|
||||||
|
async handleAssignmentListRequest() {
|
||||||
|
return [...this.manualAssignments.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAssignmentCreateRequest(request: messages.AssignmentCreateRequest) {
|
||||||
|
const { name, groupId } = request;
|
||||||
|
if (this.manualAssignments.has(name)) {
|
||||||
|
throw new lib.RequestError(`Assignment for '${name}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = new messages.AssignmentRecord(name, groupId);
|
||||||
|
this.manualAssignments.set(assignment);
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) {
|
||||||
|
const assignment = request.assignment;
|
||||||
|
if (!this.manualAssignments.has(assignment.name)) {
|
||||||
|
throw new lib.RequestError(`Assignment for '${assignment.name}' does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.manualAssignments.set(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAssignmentDeleteRequest(request: messages.AssignmentDeleteRequest) {
|
||||||
|
const { name } = request;
|
||||||
|
|
||||||
|
const assignment = this.manualAssignments.getMutable(name);
|
||||||
|
if (!assignment) {
|
||||||
|
throw new lib.RequestError(`Assignment for '${name}' does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.manualAssignments.delete(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAssignmentGetRequest(request: messages.AssignmentGetRequest) {
|
||||||
|
if (request.resolve) {
|
||||||
|
let assignment = this.resolvedAssignments.get(request.name);
|
||||||
|
if (!assignment) {
|
||||||
|
assignment = await this.computeResolvedAssignment(request.name);
|
||||||
|
this.resolvedAssignments.set(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = this.manualAssignments.get(request.name);
|
||||||
|
if (!assignment) {
|
||||||
|
throw new lib.RequestError(`Assignment for '${request.name}' does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Role mappings
|
||||||
|
*/
|
||||||
|
|
||||||
|
async handleRoleMappingListRequest() {
|
||||||
|
return [...this.roleMappings.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRoleMappingCreateRequest(request: messages.RoleMappingCreateRequest) {
|
||||||
|
let id = Math.random() * 2**31 | 0;
|
||||||
|
while (this.roleMappings.has(id)) {
|
||||||
|
id = Math.random() * 2**31 | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let priority = request.priority;
|
||||||
|
const existing = new Set([...this.roleMappings.values()].map(m => m.priority));
|
||||||
|
while (existing.has(priority)) {
|
||||||
|
priority++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleMapping = new messages.RoleMappingRecord(
|
||||||
|
id, new Set(request.roleIds), request.groupId, priority, request.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.roleMappings.set(roleMapping);
|
||||||
|
return roleMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRoleMappingUpdateRequest(request: messages.RoleMappingUpdateRequest) {
|
||||||
|
const roleMapping = request.roleMapping;
|
||||||
|
if (roleMapping.id === undefined || !this.roleMappings.has(roleMapping.id)) {
|
||||||
|
throw new lib.RequestError(`Role mapping with ID ${roleMapping.id} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set(
|
||||||
|
[...this.roleMappings.values()]
|
||||||
|
.filter(m => m.id !== roleMapping.id)
|
||||||
|
.map(m => m.priority)
|
||||||
|
);
|
||||||
|
|
||||||
|
let priority = roleMapping.priority;
|
||||||
|
while (existing.has(priority)) {
|
||||||
|
priority++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.roleMappings.set(roleMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRoleMappingDeleteRequest(request: messages.RoleMappingDeleteRequest) {
|
||||||
|
const { id } = request;
|
||||||
|
|
||||||
|
const mapping = this.roleMappings.getMutable(id);
|
||||||
|
if (!mapping) {
|
||||||
|
throw new lib.RequestError(`Role mapping with ID ${id} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.roleMappings.delete(mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRoleMappingGetRequest(request: messages.RoleMappingGetRequest) {
|
||||||
|
const mapping = this.roleMappings.get(request.id);
|
||||||
|
if (!mapping) {
|
||||||
|
throw new lib.RequestError(`Role mapping with ID ${request.id} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Calculating assignments
|
||||||
|
*/
|
||||||
|
|
||||||
|
async computeResolvedAssignment(playerName: string): Promise<messages.AssignmentRecord> {
|
||||||
|
// 1) Manual override
|
||||||
|
const manual = this.manualAssignments.get(playerName);
|
||||||
|
if (manual) {
|
||||||
|
return manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.controller.users.getByName(playerName);
|
||||||
|
const userRoles = user?.roleIds ?? new Set<number>();
|
||||||
|
|
||||||
|
// 2) Role mappings
|
||||||
|
let best: messages.RoleMappingRecord | null = null;
|
||||||
|
for (const mapping of this.roleMappings.values()) {
|
||||||
|
if (!mapping.enabled) continue;
|
||||||
|
|
||||||
|
let matches = true;
|
||||||
|
for (const roleId of mapping.roleIds) {
|
||||||
|
if (!userRoles.has(roleId)) {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches) continue;
|
||||||
|
|
||||||
|
if (!best || mapping.priority > best.priority) {
|
||||||
|
best = mapping;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best) {
|
||||||
|
return new messages.AssignmentRecord(playerName, best.groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Default (deleted assignment, assigns to 'Default' in game)
|
||||||
|
return new messages.AssignmentRecord(playerName, 0, 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeResolvedAssignments(playerNames: string[]): Promise<messages.AssignmentRecord[]> {
|
||||||
|
return Promise.all(playerNames.map(name => this.computeResolvedAssignment(name)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-64
@@ -1,84 +1,183 @@
|
|||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import * as Messages from "./messages";
|
import * as messages from "./messages";
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.create_delete_groups",
|
|
||||||
title: "Create and delete permission groups",
|
|
||||||
description: "Create and delete permission groups.",
|
|
||||||
});
|
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.reorder_groups",
|
|
||||||
title: "Reorder permission groups",
|
|
||||||
description: "Reorder groups and link them to user roles.",
|
|
||||||
});
|
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.modify_permissions",
|
|
||||||
title: "Modify permission groups",
|
|
||||||
description: "Modify game permissions for groups.",
|
|
||||||
});
|
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.assign_players",
|
|
||||||
title: "Change player group",
|
|
||||||
description: "Change the permission group of a player",
|
|
||||||
});
|
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.list",
|
|
||||||
title: "View permission groups",
|
|
||||||
description: "View permission groups.",
|
|
||||||
});
|
|
||||||
|
|
||||||
lib.definePermission({
|
|
||||||
name: "exp_groups.list.subscribe",
|
|
||||||
title: "Subscribe to permission group updates",
|
|
||||||
description: "Subscribe to permission group updates.",
|
|
||||||
});
|
|
||||||
|
|
||||||
declare module "@clusterio/lib" {
|
declare module "@clusterio/lib" {
|
||||||
export interface ControllerConfigFields {
|
|
||||||
"exp_groups.allow_role_inconsistency": boolean;
|
|
||||||
}
|
|
||||||
export interface InstanceConfigFields {
|
export interface InstanceConfigFields {
|
||||||
"exp_groups.sync_permission_groups": boolean;
|
"exp_groups.sync_mode": "enabled" | "disabled" | "bidirectional"
|
||||||
|
}
|
||||||
|
export interface ControllerConfigFields {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group permissions
|
||||||
|
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.get",
|
||||||
|
title: "Get Groups",
|
||||||
|
description: "Retrieve a specific Factorio permission group by ID.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.list",
|
||||||
|
title: "List Groups",
|
||||||
|
description: "List all Factorio permission groups.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.subscribe",
|
||||||
|
title: "Subscribe to Group Updates",
|
||||||
|
description: "Receive updates when Factorio permission groups change.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.create",
|
||||||
|
title: "Create Groups",
|
||||||
|
description: "Create new Factorio permission groups.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.update",
|
||||||
|
title: "Update Groups",
|
||||||
|
description: "Modify existing Factorio permission groups.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.group.delete",
|
||||||
|
title: "Delete Groups",
|
||||||
|
description: "Delete Factorio permission groups.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assignment permissions
|
||||||
|
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.get",
|
||||||
|
title: "Get Assignments",
|
||||||
|
description: "Retrieve a specific manual group assignment for a player.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.list",
|
||||||
|
title: "List Assignments",
|
||||||
|
description: "List all manual group assignments.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.subscribe",
|
||||||
|
title: "Subscribe to Assignment Updates",
|
||||||
|
description: "Receive updates when manual group assignments change.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.create",
|
||||||
|
title: "Create Assignments",
|
||||||
|
description: "Manually assign players to groups, overriding role mappings.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.update",
|
||||||
|
title: "Update Assignments",
|
||||||
|
description: "Modify existing manual group assignments.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.assignment.delete",
|
||||||
|
title: "Delete Assignments",
|
||||||
|
description: "Remove manual group assignments.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role mapping permissions
|
||||||
|
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.get",
|
||||||
|
title: "Get Role Mappings",
|
||||||
|
description: "Retrieve a specific role mapping rule.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.list",
|
||||||
|
title: "List Role Mappings",
|
||||||
|
description: "List all role mapping rules.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.subscribe",
|
||||||
|
title: "Subscribe to Role Mapping Updates",
|
||||||
|
description: "Receive updates when role mapping rules change.",
|
||||||
|
grantByDefault: true,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.create",
|
||||||
|
title: "Create Role Mappings",
|
||||||
|
description: "Create rules that map user roles to Factorio permission groups.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.update",
|
||||||
|
title: "Update Role Mappings",
|
||||||
|
description: "Modify existing role mapping rules.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
lib.definePermission({
|
||||||
|
name: "exp_groups.role_mapping.delete",
|
||||||
|
title: "Delete Role Mappings",
|
||||||
|
description: "Delete role mapping rules.",
|
||||||
|
grantByDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const plugin: lib.PluginDeclaration = {
|
export const plugin: lib.PluginDeclaration = {
|
||||||
name: "exp_groups",
|
name: "exp_groups",
|
||||||
title: "exp_groups",
|
title: "ExpGaming - Permission Groups",
|
||||||
description: "Create, modify, and link factorio permission groups to clusterio user roles.",
|
description: "Clusterio plugin providing syncing of permission groups",
|
||||||
|
|
||||||
controllerEntrypoint: "./dist/node/controller",
|
features: [
|
||||||
controllerConfigFields: {
|
"SavePatching",
|
||||||
"exp_groups.allow_role_inconsistency": {
|
"ScriptCommands",
|
||||||
title: "Allow User Role Inconsistency",
|
],
|
||||||
description: "When true, users can be assgined to any group regardless of their roles",
|
|
||||||
type: "boolean",
|
messages: [
|
||||||
initialValue: false,
|
messages.GroupUpdatedEvent,
|
||||||
},
|
messages.ManualAssignmentUpdatedEvent,
|
||||||
},
|
messages.ResolvedAssignmentUpdatedEvent,
|
||||||
|
messages.RoleMappingUpdatedEvent,
|
||||||
|
|
||||||
|
messages.GroupCreateRequest,
|
||||||
|
messages.GroupUpdateRequest,
|
||||||
|
messages.GroupDeleteRequest,
|
||||||
|
messages.GroupGetRequest,
|
||||||
|
messages.GroupListRequest,
|
||||||
|
|
||||||
|
messages.AssignmentCreateRequest,
|
||||||
|
messages.AssignmentUpdateRequest,
|
||||||
|
messages.AssignmentDeleteRequest,
|
||||||
|
messages.AssignmentGetRequest,
|
||||||
|
messages.AssignmentListRequest,
|
||||||
|
|
||||||
|
messages.RoleMappingCreateRequest,
|
||||||
|
messages.RoleMappingUpdateRequest,
|
||||||
|
messages.RoleMappingDeleteRequest,
|
||||||
|
messages.RoleMappingGetRequest,
|
||||||
|
messages.RoleMappingListRequest,
|
||||||
|
],
|
||||||
|
|
||||||
instanceEntrypoint: "./dist/node/instance",
|
instanceEntrypoint: "./dist/node/instance",
|
||||||
instanceConfigFields: {
|
instanceConfigFields: {
|
||||||
"exp_groups.sync_permission_groups": {
|
"exp_groups.sync_mode": {
|
||||||
title: "Sync Permission Groups",
|
description: "Synchronize permission groups with the controller",
|
||||||
description: "When true, the instance cannot deviate from the global group settings and will be hidden from the sellection dropdown.",
|
type: "string",
|
||||||
type: "boolean",
|
enum: ["disabled", "enabled", "bidirectional"],
|
||||||
initialValue: true,
|
initialValue: "bidirectional",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: [
|
controllerEntrypoint: "./dist/node/controller",
|
||||||
Messages.PermissionGroupEditEvent,
|
controllerConfigFields: {
|
||||||
Messages.PermissionStringsUpdate,
|
},
|
||||||
Messages.PermissionGroupUpdate,
|
|
||||||
],
|
|
||||||
|
|
||||||
webEntrypoint: "./web",
|
webEntrypoint: "./web",
|
||||||
routes: [
|
routes: [
|
||||||
"/exp_groups",
|
"/permission_groups",
|
||||||
],
|
"/permission_groups/:id/view",
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
+152
-110
@@ -1,129 +1,171 @@
|
|||||||
import * as lib from "@clusterio/lib";
|
|
||||||
import { BaseInstancePlugin } from "@clusterio/host";
|
import { BaseInstancePlugin } from "@clusterio/host";
|
||||||
import {
|
import * as lib from "@clusterio/lib";
|
||||||
PermissionGroup, PermissionGroupEditEvent, PermissionGroupEditType,
|
import * as messages from "./messages";
|
||||||
PermissionGroupUpdate, PermissionInstanceId, PermissionStrings, PermissionStringsUpdate
|
|
||||||
} from "./messages";
|
|
||||||
|
|
||||||
const rconBase = "/sc local Groups = package.loaded['modules/exp_groups/module_exports'];"
|
export type IpcGroupUpdated = {
|
||||||
|
group_name: string,
|
||||||
type EditIPC = {
|
group_id: number | undefined,
|
||||||
type: PermissionGroupEditType,
|
permissions: { is_blacklist: boolean, permissions: string[] | undefined },
|
||||||
changes: string[],
|
|
||||||
group: string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateIPC = {
|
export type IpcGroupDeleted = {
|
||||||
group: string,
|
group_name: string,
|
||||||
defiantion: [boolean, string[] | {}]
|
group_id: number | undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
type DeleteIPC = {
|
export type IpcPlayerAssignments = {
|
||||||
group: string,
|
assignments: Record<string, number>,
|
||||||
}
|
};
|
||||||
|
|
||||||
export class InstancePlugin extends BaseInstancePlugin {
|
export class InstancePlugin extends BaseInstancePlugin {
|
||||||
permissions: Set<string> = new Set();
|
// Once only, don't send permissions for these groups
|
||||||
permissionGroups = new lib.EventSubscriber(PermissionGroupUpdate, this.instance);
|
// This is used for groups created on this instance that only need the controller generated id
|
||||||
permissionGroupUpdates = new lib.EventSubscriber(PermissionGroupEditEvent, this.instance);
|
skipSendingPermissions = new Set<string>();
|
||||||
syncId: PermissionInstanceId = this.instance.config.get("exp_groups.sync_permission_groups") ? "Global" : this.instance.id;
|
// 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>();
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.instance.server.handle("exp_groups-permission_group_edit", this.handleEditIPC.bind(this));
|
this.instance.handle(messages.GroupUpdatedEvent, this.handleGroupUpdatedEvent.bind(this));
|
||||||
this.instance.server.handle("exp_groups-permission_group_create", this.handleCreateIPC.bind(this));
|
this.instance.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentUpdatedEvent.bind(this));
|
||||||
this.instance.server.handle("exp_groups-permission_group_delete", this.handleDeleteIPC.bind(this));
|
this.instance.server.handle(`exp_group:group_updated`, this.handleGroupUpdatedIPC.bind(this))
|
||||||
}
|
this.instance.server.handle(`exp_group:group_deleted`, this.handleGroupDeletedIPC.bind(this))
|
||||||
|
this.instance.server.handle(`exp_group:player_assignments`, this.handlePlayerAssignmentsIPC.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
async onStart() {
|
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||||
// Send the most recent version of the permission string
|
switch(field) {
|
||||||
const permissionsString = await this.sendRcon(rconBase + "rcon.print(Groups.get_actions_json())");
|
case "exp_groups.sync_mode":
|
||||||
this.permissions = new Set(JSON.parse(permissionsString));
|
await this.luaSetEmitEvents(curr == "bidirectional")
|
||||||
this.instance.sendTo("controller", new PermissionStringsUpdate([
|
break;
|
||||||
new PermissionStrings(this.instance.id, this.permissions, Date.now())
|
}
|
||||||
]));
|
}
|
||||||
|
|
||||||
// Subscribe to get updates for permission groups
|
async onStart() {
|
||||||
this.permissionGroups.subscribe(this.onPermissionGroupsUpdate.bind(this));
|
// We use Date.now() because we need to manually initialise the groups on the lua side
|
||||||
this.permissionGroupUpdates.subscribe(this.onPermissionGroupUpdate.bind(this));
|
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||||
}
|
`exp_groups:${messages.GroupUpdatedEvent.name}`, true, Date.now()
|
||||||
|
));
|
||||||
|
const groups = await this.instance.sendTo("controller", new messages.GroupListRequest())
|
||||||
|
await this.luaSendInitialGroups(groups);
|
||||||
|
await this.luaSetEmitEvents(this.instance.config.get("exp_groups.sync_mode") == "bidirectional")
|
||||||
|
}
|
||||||
|
|
||||||
async onControllerConnectionEvent(event: any) {
|
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||||
this.permissionGroups.handleConnectionEvent(event);
|
switch(event.type) {
|
||||||
}
|
case "join":
|
||||||
|
this.onlinePlayers.add(event.name);
|
||||||
|
await this.subscribePlayerAssignment(event.name);
|
||||||
|
break;
|
||||||
|
case "leave":
|
||||||
|
this.onlinePlayers.delete(event.name);
|
||||||
|
await this.unsubscribePlayerAssignment(event.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) {
|
||||||
if (field === "exp_groups.sync_permission_groups") {
|
for (const group of event.updates) {
|
||||||
this.syncId = curr ? "Global" : this.instance.id;
|
await this.luaSendGroupUpdate(group);
|
||||||
const [snapshot, synced] = this.permissionGroups.getSnapshot();
|
}
|
||||||
if (synced && this.instance.status !== "running") await this.syncPermissionGroups(snapshot.values());
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onPermissionGroupsUpdate(event: PermissionGroupUpdate | null, synced: boolean) {
|
async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) {
|
||||||
if (!synced || this.instance.status !== "running" || !event?.updates.length) return;
|
for (const assignment of event.updates) {
|
||||||
await this.syncPermissionGroups(event.updates);
|
if (this.onlinePlayers.has(assignment.name)) {
|
||||||
}
|
await this.luaSendAssignmentUpdate(assignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async syncPermissionGroups(groups: Iterable<PermissionGroup>) {
|
async handleGroupUpdatedIPC(event: IpcGroupUpdated) {
|
||||||
const updateCommands = [rconBase];
|
const permissions = new messages.GroupPermissions(
|
||||||
for (const group of groups) {
|
event.permissions.is_blacklist,
|
||||||
if (group.instanceId === this.syncId && group.updatedAtMs > (this.permissionGroups.values.get(group.id)?.updatedAtMs ?? 0)) {
|
event.permissions.permissions ?? [],
|
||||||
if (group.isDeleted) {
|
)
|
||||||
updateCommands.push(`Groups.destroy_group('${group.name}')`);
|
|
||||||
} else if (group.permissions.size < this.permissions.size / 2) {
|
|
||||||
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([false, [...this.permissions.values()]])}')`);
|
|
||||||
} else {
|
|
||||||
const inverted = [...this.permissions.values()].filter(permission => !group.permissions.has(permission));
|
|
||||||
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([true, inverted])}')`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.sendRcon(updateCommands.join(";"), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onPermissionGroupUpdate(event: PermissionGroupEditEvent | null, synced: boolean) {
|
if (event.group_id === undefined) {
|
||||||
if (!synced || this.instance.status !== "running" || !event) return;
|
this.skipSendingPermissions.add(event.group_name);
|
||||||
if (event.src.equals(lib.Address.fromShorthand({ instanceId: this.instance.id }))) return;
|
await this.instance.sendTo("controller",
|
||||||
const getCmd = `Groups.get_or_create('${event.group}')`;
|
new messages.GroupCreateRequest(event.group_name, permissions),
|
||||||
if (event.type === "add_permissions") {
|
);
|
||||||
await this.sendRcon(rconBase + getCmd + `:allow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
|
} else {
|
||||||
} else if (event.type === "remove_permissions") {
|
this.skipSendingUpdate.add(event.group_id);
|
||||||
await this.sendRcon(rconBase + getCmd + `:disallow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
|
await this.instance.sendTo("controller", new messages.GroupUpdateRequest(
|
||||||
} else if (event.type === "assign_players") {
|
new messages.GroupRecord(event.group_id, event.group_name, permissions),
|
||||||
await this.sendRcon(rconBase + getCmd + `:add_players(game.json_to_table('${JSON.stringify(event.changes)}'))`);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleEditIPC(event: EditIPC) {
|
async handleGroupDeletedIPC(event: IpcGroupDeleted) {
|
||||||
this.logger.info(JSON.stringify(event))
|
if (event.group_id === undefined) {
|
||||||
this.instance.sendTo("controller", new PermissionGroupEditEvent(
|
return;
|
||||||
lib.Address.fromShorthand({ instanceId: this.instance.id }),
|
}
|
||||||
event.type, event.group, event.changes
|
this.skipSendingUpdate.add(event.group_id);
|
||||||
))
|
await this.instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleCreateIPC(event: CreateIPC) {
|
async handlePlayerAssignmentsIPC(event: IpcPlayerAssignments) {
|
||||||
this.logger.info(JSON.stringify(event))
|
await Promise.all(
|
||||||
if (!this.permissionGroups.synced) return;
|
Object.entries(event.assignments).map(([playerName, groupId]) =>
|
||||||
let [defaultAllow, permissionsRaw] = event.defiantion;
|
this.instance.sendTo("controller",
|
||||||
if (!Array.isArray(permissionsRaw)) {
|
new messages.AssignmentUpdateRequest(new messages.AssignmentRecord(playerName, groupId)),
|
||||||
permissionsRaw = [] // lua outputs {} for empty arrays
|
)
|
||||||
}
|
)
|
||||||
const permissions = [...this.permissions.values()]
|
);
|
||||||
.filter(permission => defaultAllow !== (permissionsRaw as String[]).includes(permission));
|
}
|
||||||
this.instance.sendTo("controller", new PermissionGroupUpdate([ new PermissionGroup(
|
|
||||||
this.syncId, event.group, 0, new Set(), new Set(permissions)
|
|
||||||
) ]));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDeleteIPC(event: DeleteIPC) {
|
async subscribePlayerAssignment(playerName: string) {
|
||||||
if (!this.permissionGroups.synced) return;
|
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||||
const group = [...this.permissionGroups.values.values()]
|
`exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, true, 0, playerName
|
||||||
.find(group => group.instanceId === this.syncId && group.name === event.group);
|
));
|
||||||
if (group) {
|
}
|
||||||
group.updatedAtMs = Date.now();
|
|
||||||
group.isDeleted = true;
|
async unsubscribePlayerAssignment(playerName: string) {
|
||||||
this.instance.sendTo("controller", new PermissionGroupUpdate([ group ]));
|
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||||
}
|
`exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, false, 0, playerName
|
||||||
}
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async luaSendInitialGroups(groups: messages.GroupRecord[]) {
|
||||||
|
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.luaSend("initialise_groups", groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
async luaSendGroupUpdate(group: messages.GroupRecord) {
|
||||||
|
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.skipSendingUpdate.has(group.id)) {
|
||||||
|
this.skipSendingUpdate.delete(group.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = group.toJSON();
|
||||||
|
if (this.skipSendingPermissions.has(group.name)) {
|
||||||
|
this.skipSendingPermissions.delete(group.name);
|
||||||
|
delete (json as any).permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.luaSend("receive_group_update", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) {
|
||||||
|
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.luaSend("receive_assignment_update", assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async luaSetEmitEvents(emitEvents: boolean) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+655
-202
@@ -1,239 +1,692 @@
|
|||||||
import { User, InstanceDetails, IControllerUser, Link, MessageRequest, StringEnum, PermissionError, Address } from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import { Type, Static } from "@sinclair/typebox";
|
import { Type, Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
export const PermissionInstanceIdSchema = Type.Union([InstanceDetails.jsonSchema.properties.id, Type.Literal("Global")])
|
/*
|
||||||
export type PermissionInstanceId = InstanceDetails["id"] | "Global"
|
Data records
|
||||||
export type GamePermission = string; // todo: maybe enum this?
|
*/
|
||||||
|
|
||||||
/**
|
export class GroupPermissions {
|
||||||
* Data class for permission groups
|
constructor(
|
||||||
*/
|
public isBlacklist: boolean,
|
||||||
export class PermissionGroup {
|
public permissions: string[],
|
||||||
constructor(
|
) {}
|
||||||
public instanceId: PermissionInstanceId,
|
|
||||||
public name: string,
|
|
||||||
/** A lower order assumes a lower permission group */
|
|
||||||
public order: number = 0,
|
|
||||||
/** A role will use the highest order group it is apart of */
|
|
||||||
public roleIds: User["roleIds"] = new Set(),
|
|
||||||
public permissions: Set<GamePermission> = new Set(),
|
|
||||||
public updatedAtMs: number = 0,
|
|
||||||
public isDeleted: boolean = false,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
static jsonSchema = Type.Object({
|
||||||
instanceId: PermissionInstanceIdSchema,
|
is_blacklist: Type.Boolean(),
|
||||||
name: Type.String(),
|
permissions: Type.Array(Type.String()),
|
||||||
order: Type.Number(),
|
});
|
||||||
roleIds: Type.Array(Type.Number()),
|
|
||||||
permissions: Type.Array(Type.String()),
|
|
||||||
updatedAtMs: Type.Optional(Type.Number()),
|
|
||||||
isDeleted: Type.Optional(Type.Boolean()),
|
|
||||||
});
|
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
toJSON(): Static<typeof GroupPermissions.jsonSchema> {
|
||||||
return new this(
|
return {
|
||||||
json.instanceId,
|
is_blacklist: this.isBlacklist,
|
||||||
json.name,
|
permissions: this.permissions,
|
||||||
json.order,
|
};
|
||||||
new Set(json.roleIds),
|
}
|
||||||
new Set(json.permissions),
|
|
||||||
json.updatedAtMs,
|
|
||||||
json.isDeleted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): Static<typeof PermissionGroup.jsonSchema> {
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
return {
|
return new this(
|
||||||
instanceId: this.instanceId,
|
json.is_blacklist,
|
||||||
name: this.name,
|
json.permissions,
|
||||||
order: this.order,
|
);
|
||||||
roleIds: [...this.roleIds.values()],
|
}
|
||||||
permissions: [...this.permissions.values()],
|
|
||||||
updatedAtMs: this.updatedAtMs > 0 ? this.updatedAtMs : undefined,
|
|
||||||
isDeleted: this.isDeleted ? this.isDeleted : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get id() {
|
|
||||||
return `${this.instanceId}:${this.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
copy(newInstanceId: PermissionInstanceId) {
|
|
||||||
return new PermissionGroup(
|
|
||||||
newInstanceId,
|
|
||||||
this.name,
|
|
||||||
this.order,
|
|
||||||
new Set(this.roleIds),
|
|
||||||
new Set(this.permissions),
|
|
||||||
Date.now(),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstancePermissionGroups {
|
export class GroupRecord {
|
||||||
constructor(
|
constructor(
|
||||||
public instanceId: PermissionInstanceId,
|
public id: number,
|
||||||
public groups: Map<PermissionGroup["name"], PermissionGroup> = new Map(),
|
public name: string,
|
||||||
) {
|
public permissions: GroupPermissions,
|
||||||
}
|
public updatedAtMs: number = 0,
|
||||||
|
public isDeleted: boolean = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
static jsonSchema = Type.Object({
|
||||||
instanceId: PermissionInstanceIdSchema,
|
id: Type.Integer(),
|
||||||
permissionsGroups: Type.Array(PermissionGroup.jsonSchema),
|
name: Type.String(),
|
||||||
});
|
permissions: GroupPermissions.jsonSchema,
|
||||||
|
updated_at_ms: Type.Optional(Type.Number()),
|
||||||
|
is_deleted: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof InstancePermissionGroups.jsonSchema>) {
|
toJSON() {
|
||||||
return new InstancePermissionGroups(
|
let json: Static<typeof GroupRecord.jsonSchema> = {
|
||||||
json.instanceId,
|
id: this.id,
|
||||||
new Map(json.permissionsGroups.map(group => [group.name, PermissionGroup.fromJSON(group)])),
|
name: this.name,
|
||||||
);
|
permissions: this.permissions.toJSON(),
|
||||||
}
|
};
|
||||||
|
|
||||||
toJSON() {
|
if (this.updatedAtMs) {
|
||||||
return {
|
json.updated_at_ms = this.updatedAtMs;
|
||||||
instanceId: this.instanceId,
|
}
|
||||||
permissionsGroups: [...this.groups.values()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserGroup(user: User) {
|
if (this.isDeleted) {
|
||||||
const groups = [...user.roleIds.values()].map(roleId =>
|
json.is_deleted = true;
|
||||||
// There will always be one and only one group for each role
|
}
|
||||||
[...this.groups.values()].find(group => group.roleIds.has(roleId))!
|
|
||||||
);
|
|
||||||
return groups.reduce((highest, group) => highest.order > group.order ? highest : group);
|
|
||||||
}
|
|
||||||
|
|
||||||
get id() {
|
return json;
|
||||||
return this.instanceId;
|
}
|
||||||
}
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.id,
|
||||||
|
json.name,
|
||||||
|
GroupPermissions.fromJSON(json.permissions),
|
||||||
|
json.updated_at_ms ?? 0,
|
||||||
|
json.is_deleted ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PermissionGroupUpdate {
|
export class AssignmentRecord {
|
||||||
declare ["constructor"]: typeof PermissionGroupUpdate;
|
constructor(
|
||||||
static type = "event" as const;
|
public name: string,
|
||||||
static src = ["controller", "instance"] as const;
|
public groupId: number,
|
||||||
static dst = ["control", "instance", "controller"] as const;
|
public updatedAtMs: number = 0,
|
||||||
static plugin = "exp_groups" as const;
|
public isDeleted: boolean = false,
|
||||||
static permission = "exp_groups.list.subscribe";
|
) {}
|
||||||
|
|
||||||
constructor(
|
get id() {
|
||||||
public updates: PermissionGroup[],
|
return this.name;
|
||||||
) { }
|
}
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
static jsonSchema = Type.Object({
|
||||||
"updates": Type.Array(PermissionGroup.jsonSchema),
|
name: Type.String(),
|
||||||
});
|
group_id: Type.Integer(),
|
||||||
|
updated_at_ms: Type.Optional(Type.Number()),
|
||||||
|
is_deleted: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
toJSON(): Static<typeof AssignmentRecord.jsonSchema> {
|
||||||
return new this(
|
let json: Static<typeof AssignmentRecord.jsonSchema> = {
|
||||||
json.updates.map(update => PermissionGroup.fromJSON(update))
|
name: this.name,
|
||||||
);
|
group_id: this.groupId,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
if (this.updatedAtMs) {
|
||||||
|
json.updated_at_ms = this.updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDeleted) {
|
||||||
|
json.is_deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.name,
|
||||||
|
json.group_id,
|
||||||
|
json.updated_at_ms ?? 0,
|
||||||
|
json.is_deleted ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionGroupEditType = "assign_players" | "add_permissions" | "remove_permissions";
|
export class RoleMappingRecord {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public roleIds: Set<number>,
|
||||||
|
public groupId: number,
|
||||||
|
public priority: number,
|
||||||
|
public enabled: boolean,
|
||||||
|
public updatedAtMs: number = 0,
|
||||||
|
public isDeleted: boolean = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
export class PermissionGroupEditEvent {
|
static jsonSchema = Type.Object({
|
||||||
declare ["constructor"]: typeof PermissionGroupEditEvent;
|
id: Type.Integer(),
|
||||||
static type = "event" as const;
|
role_ids: Type.Array(Type.Integer()),
|
||||||
static src = ["instance", "controller"] as const;
|
group_id: Type.Integer(),
|
||||||
static dst = ["control", "instance", "controller"] as const;
|
priority: Type.Number(),
|
||||||
static plugin = "exp_groups" as const;
|
enabled: Type.Boolean(),
|
||||||
|
updated_at_ms: Type.Optional(Type.Number()),
|
||||||
|
is_deleted: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
static permission(user: IControllerUser, message: MessageRequest) {
|
toJSON(): Static<typeof RoleMappingRecord.jsonSchema> {
|
||||||
if (typeof message.data === "object" && message.data !== null) {
|
let json: Static<typeof RoleMappingRecord.jsonSchema> = {
|
||||||
const data = message.data as Static<typeof PermissionGroupEditEvent.jsonSchema>;
|
id: this.id,
|
||||||
if (data.type === "add_permissions" || data.type === "remove_permissions") {
|
role_ids: [...this.roleIds],
|
||||||
user.checkPermission("exp_groups.modify_permissions")
|
group_id: this.groupId,
|
||||||
} else if (data.type === "assign_players") {
|
priority: this.priority,
|
||||||
user.checkPermission("exp_groups.assign_players")
|
enabled: this.enabled,
|
||||||
} else {
|
};
|
||||||
throw new PermissionError("Permission denied");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
if (this.updatedAtMs) {
|
||||||
public src: Address,
|
json.updated_at_ms = this.updatedAtMs;
|
||||||
public type: PermissionGroupEditType,
|
}
|
||||||
public group: string,
|
|
||||||
public changes: String[],
|
|
||||||
) { }
|
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
if (this.isDeleted) {
|
||||||
"src": Address.jsonSchema,
|
json.is_deleted = true;
|
||||||
"type": StringEnum(["assign_players", "add_permissions", "remove_permissions"]),
|
}
|
||||||
"group": Type.String(),
|
|
||||||
"changes": Type.Array(Type.String()),
|
|
||||||
});
|
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
return json;
|
||||||
return new this(Address.fromJSON(json.src), json.type, json.group, json.changes);
|
}
|
||||||
}
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.id,
|
||||||
|
new Set(json.role_ids),
|
||||||
|
json.group_id,
|
||||||
|
json.priority,
|
||||||
|
json.enabled,
|
||||||
|
json.updated_at_ms ?? 0,
|
||||||
|
json.is_deleted ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PermissionStrings {
|
/*
|
||||||
constructor(
|
Update events
|
||||||
public instanceId: PermissionInstanceId,
|
*/
|
||||||
public permissions: Set<GamePermission>,
|
|
||||||
public updatedAtMs: number = 0,
|
|
||||||
public isDeleted: boolean = false,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
export class GroupUpdatedEvent {
|
||||||
instanceId: PermissionInstanceIdSchema,
|
declare ["constructor"]: typeof GroupUpdatedEvent;
|
||||||
permissions: Type.Array(Type.String()),
|
static plugin = "exp_groups" as const;
|
||||||
updatedAtMs: Type.Optional(Type.Number()),
|
static type = "event" as const;
|
||||||
isDeleted: Type.Optional(Type.Boolean()),
|
static src = "controller" as const;
|
||||||
});
|
static dst = ["control", "instance"] as const;
|
||||||
|
static permission = "exp_groups.group.subscribe" as const;
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof PermissionStrings.jsonSchema>) {
|
constructor(
|
||||||
return new PermissionStrings(
|
public updates: GroupRecord[],
|
||||||
json.instanceId,
|
) {}
|
||||||
new Set(json.permissions),
|
|
||||||
json.updatedAtMs,
|
|
||||||
json.isDeleted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
static jsonSchema = Type.Object({
|
||||||
return {
|
updates: Type.Array(GroupRecord.jsonSchema),
|
||||||
instanceId: this.instanceId,
|
});
|
||||||
permissions: [...this.permissions.values()],
|
|
||||||
updatedAtMs: this.updatedAtMs > 0 ? this.updatedAtMs : undefined,
|
|
||||||
isDeleted: this.isDeleted ? this.isDeleted : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get id() {
|
toJSON() {
|
||||||
return this.instanceId
|
return { updates: this.updates.map(group => group.toJSON()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.updates.map(group => GroupRecord.fromJSON(group)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PermissionStringsUpdate {
|
export class ManualAssignmentUpdatedEvent {
|
||||||
declare ["constructor"]: typeof PermissionStringsUpdate;
|
declare ["constructor"]: typeof ManualAssignmentUpdatedEvent;
|
||||||
static type = "event" as const;
|
static plugin = "exp_groups" as const;
|
||||||
static src = ["instance", "controller"] as const;
|
static type = "event" as const;
|
||||||
static dst = ["controller", "control"] as const;
|
static src = "controller" as const;
|
||||||
static plugin = "exp_groups" as const;
|
static dst = ["control", "instance"] as const;
|
||||||
static permission = "exp_groups.list.subscribe";
|
static permission = "exp_groups.assignment.subscribe" as const;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public updates: PermissionStrings[],
|
public updates: AssignmentRecord[],
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
static jsonSchema = Type.Object({
|
||||||
"updates": Type.Array(PermissionStrings.jsonSchema),
|
updates: Type.Array(AssignmentRecord.jsonSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
toJSON() {
|
||||||
return new this(
|
return { updates: this.updates.map(assignment => assignment.toJSON()) };
|
||||||
json.updates.map(update => PermissionStrings.fromJSON(update))
|
}
|
||||||
);
|
|
||||||
}
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResolvedAssignmentUpdatedEvent {
|
||||||
|
declare ["constructor"]: typeof ResolvedAssignmentUpdatedEvent;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "event" as const;
|
||||||
|
static src = "controller" as const;
|
||||||
|
static dst = ["control", "instance"] as const;
|
||||||
|
static permission = "exp_groups.assignment.subscribe" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public updates: AssignmentRecord[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
updates: Type.Array(AssignmentRecord.jsonSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return { updates: this.updates.map(assignment => assignment.toJSON()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleMappingUpdatedEvent {
|
||||||
|
declare ["constructor"]: typeof RoleMappingUpdatedEvent;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "event" as const;
|
||||||
|
static src = "controller" as const;
|
||||||
|
static dst = "control" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.subscribe" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public updates: RoleMappingRecord[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
updates: Type.Array(RoleMappingRecord.jsonSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return { updates: this.updates.map(roleMapping => roleMapping.toJSON()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.updates.map(roleMapping => RoleMappingRecord.fromJSON(roleMapping)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Group requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class GroupCreateRequest {
|
||||||
|
declare ["constructor"]: typeof GroupCreateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.group.create" as const;
|
||||||
|
static Response = GroupRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public name: string,
|
||||||
|
public permissions: GroupPermissions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
permissions: GroupPermissions.jsonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
permissions: this.permissions.toJSON(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.name,
|
||||||
|
GroupPermissions.fromJSON(json.permissions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupUpdateRequest {
|
||||||
|
declare ["constructor"]: typeof GroupUpdateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.group.update" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public group: GroupRecord,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
group: GroupRecord.jsonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
group: this.group.toJSON(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
GroupRecord.fromJSON(json.group),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupDeleteRequest {
|
||||||
|
declare ["constructor"]: typeof GroupDeleteRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.group.delete" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public groupId: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
group_id: Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
group_id: this.groupId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.group_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupGetRequest {
|
||||||
|
declare ["constructor"]: typeof GroupGetRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.group.get" as const;
|
||||||
|
static Response = GroupRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public groupId: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
group_id: Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
group_id: this.groupId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.group_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupListRequest {
|
||||||
|
declare ["constructor"]: typeof GroupListRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.group.list" as const;
|
||||||
|
static Response = lib.jsonArray(GroupRecord);
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Assignment requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AssignmentCreateRequest {
|
||||||
|
declare ["constructor"]: typeof AssignmentCreateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.assignment.create" as const;
|
||||||
|
static Response = AssignmentRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public name: string,
|
||||||
|
public groupId: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
group_id: Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
group_id: this.groupId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.name, json.group_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignmentUpdateRequest {
|
||||||
|
declare ["constructor"]: typeof AssignmentUpdateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.assignment.update" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public assignment: AssignmentRecord,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
assignment: AssignmentRecord.jsonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
assignment: this.assignment.toJSON(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
AssignmentRecord.fromJSON(json.assignment),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignmentDeleteRequest {
|
||||||
|
declare ["constructor"]: typeof AssignmentDeleteRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.assignment.delete" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignmentGetRequest {
|
||||||
|
declare ["constructor"]: typeof AssignmentGetRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.assignment.get" as const;
|
||||||
|
static Response = AssignmentRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public name: string,
|
||||||
|
public resolve: boolean = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
resolve: Type.Boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
resolve: this.resolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.name, json.resolve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignmentListRequest {
|
||||||
|
declare ["constructor"]: typeof AssignmentListRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.assignment.list" as const;
|
||||||
|
static Response = lib.jsonArray(AssignmentRecord);
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Role mapping requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RoleMappingCreateRequest {
|
||||||
|
declare ["constructor"]: typeof RoleMappingCreateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.create" as const;
|
||||||
|
static Response = RoleMappingRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public roleIds: number[],
|
||||||
|
public groupId: number,
|
||||||
|
public priority: number,
|
||||||
|
public enabled: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
role_ids: Type.Array(Type.Integer()),
|
||||||
|
group_id: Type.Integer(),
|
||||||
|
priority: Type.Number(),
|
||||||
|
enabled: Type.Boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
role_ids: this.roleIds,
|
||||||
|
group_id: this.groupId,
|
||||||
|
priority: this.priority,
|
||||||
|
enabled: this.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.role_ids,
|
||||||
|
json.group_id,
|
||||||
|
json.priority,
|
||||||
|
json.enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleMappingUpdateRequest {
|
||||||
|
declare ["constructor"]: typeof RoleMappingUpdateRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.update" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public roleMapping: RoleMappingRecord,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
role_mapping: RoleMappingRecord.jsonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
role_mapping: this.roleMapping.toJSON(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
RoleMappingRecord.fromJSON(json.role_mapping),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleMappingDeleteRequest {
|
||||||
|
declare ["constructor"]: typeof RoleMappingDeleteRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.delete" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
id: Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleMappingGetRequest {
|
||||||
|
declare ["constructor"]: typeof RoleMappingGetRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.get" as const;
|
||||||
|
static Response = RoleMappingRecord;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
id: Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleMappingListRequest {
|
||||||
|
declare ["constructor"]: typeof RoleMappingListRequest;
|
||||||
|
static plugin = "exp_groups" as const;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = ["control", "instance"] as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static permission = "exp_groups.role_mapping.list" as const;
|
||||||
|
static Response = lib.jsonArray(RoleMappingRecord);
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+419
-108
@@ -1,127 +1,438 @@
|
|||||||
|
--[[-- ExpGroups
|
||||||
|
Adds permission group syncing to clusterio
|
||||||
|
]]
|
||||||
|
|
||||||
local clusterio_api = require("modules/clusterio/api")
|
local clusterio_api = require("modules/clusterio/api")
|
||||||
local Global = require("modules/exp_util/global")
|
local compat = require("modules/clusterio/compat")
|
||||||
local Groups = require("modules/exp_groups")
|
|
||||||
|
|
||||||
local pending_updates = {}
|
--- Top level module table, contains event handlers and public methods
|
||||||
Global.register(pending_updates, function(tbl)
|
--- @class ExpGroups
|
||||||
pending_updates = tbl
|
local ExpGroups = {}
|
||||||
end)
|
|
||||||
|
|
||||||
|
--- @class ExpPermissionGroups.GroupPermissions
|
||||||
|
--- @field is_blacklist boolean
|
||||||
|
--- @field permissions string[]?
|
||||||
|
|
||||||
|
--- @class ExpPermissionGroups.GroupRecord
|
||||||
|
--- @field id number
|
||||||
|
--- @field name string
|
||||||
|
--- @field permissions ExpPermissionGroups.GroupPermissions?
|
||||||
|
--- @field is_deleted boolean
|
||||||
|
|
||||||
|
--- @class ExpPermissionGroups.AssignmentRecord
|
||||||
|
--- @field name string
|
||||||
|
--- @field group_id number
|
||||||
|
--- @field is_deleted boolean
|
||||||
|
|
||||||
|
--- @class ExpPermissionGroups.ScriptData
|
||||||
|
--- @field factorio_to_clusterio_id table<number, number?>
|
||||||
|
--- @field clusterio_id_to_group table<number, LuaPermissionGroup?>
|
||||||
|
--- @field dirty_groups table<number, LuaPermissionGroup>
|
||||||
|
--- @field dirty_players table<string, LuaPlayer>
|
||||||
|
--- @field emit_updates boolean
|
||||||
|
local script_data = {}
|
||||||
|
|
||||||
|
local function setup_script_data()
|
||||||
|
if compat.script_data["exp_groups"] == nil then
|
||||||
|
--- @type ExpPermissionGroups.ScriptData
|
||||||
|
compat.script_data["exp_groups"] = {
|
||||||
|
factorio_to_clusterio_id = {},
|
||||||
|
clusterio_id_to_group = {},
|
||||||
|
dirty_groups = {},
|
||||||
|
dirty_players = {},
|
||||||
|
emit_updates = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
ExpGroups.on_load()
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Helper methods
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Get the default factorio permission group
|
||||||
|
--- @return LuaPermissionGroup
|
||||||
|
local function get_default_group()
|
||||||
|
return assert(game.permissions.get_group("Default"))
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Move all players from one group to another
|
||||||
|
--- @param group_src LuaPermissionGroup
|
||||||
|
--- @param group_dst LuaPermissionGroup
|
||||||
|
local function move_players(group_src, group_dst)
|
||||||
|
local add_player = group_dst.add_player
|
||||||
|
for _, player in pairs(group_src.players) do
|
||||||
|
add_player(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply an encoded permission definition to a group
|
||||||
|
--- @param group LuaPermissionGroup
|
||||||
|
--- @param permissions ExpPermissionGroups.GroupPermissions
|
||||||
|
local function decode_group_permissions(group, permissions)
|
||||||
|
-- Construct a hash map for faster lookup
|
||||||
|
local action_map = {}
|
||||||
|
for _, input_action_name in pairs(assert(permissions.permissions)) do
|
||||||
|
action_map[input_action_name] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply the whitelist / backlist to the group
|
||||||
|
local is_blacklist = permissions.is_blacklist
|
||||||
|
local action_allowed = not is_blacklist
|
||||||
|
for input_action_name, input_action in pairs(defines.input_action) do
|
||||||
|
if action_map[input_action_name] then
|
||||||
|
group.set_allows_action(input_action, action_allowed)
|
||||||
|
else
|
||||||
|
group.set_allows_action(input_action, is_blacklist)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Encode the permissions of a group in a shorthand format
|
||||||
|
--- @param group LuaPermissionGroup
|
||||||
|
--- @return ExpPermissionGroups.GroupPermissions
|
||||||
|
local function encode_group_permissions(group)
|
||||||
|
local whitelist = {} --- @type string[]
|
||||||
|
local blacklist = {} --- @type string[]
|
||||||
|
local whitelist_index = 0
|
||||||
|
local blacklist_index = 0
|
||||||
|
|
||||||
|
-- Construct the whitelist and blacklist
|
||||||
|
local allows_action = group.allows_action
|
||||||
|
for input_action_name, input_action in pairs(defines.input_action) do
|
||||||
|
if allows_action(input_action) then
|
||||||
|
whitelist[whitelist_index] = input_action_name
|
||||||
|
whitelist_index = whitelist_index + 1
|
||||||
|
else
|
||||||
|
blacklist[blacklist_index] = input_action_name
|
||||||
|
blacklist_index = blacklist_index + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the whitelist if it is smaller
|
||||||
|
if blacklist_index > whitelist_index then
|
||||||
|
return whitelist_index > 0
|
||||||
|
and { is_blacklist = false, permissions = whitelist }
|
||||||
|
or { is_blacklist = false }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Otherwise return the blacklist as it is smaller
|
||||||
|
return blacklist_index > 0
|
||||||
|
and { is_blacklist = true, permissions = blacklist }
|
||||||
|
or { is_blacklist = true }
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
State handlers
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Update the factorio permission group
|
||||||
|
--- @param group_record ExpPermissionGroups.GroupRecord
|
||||||
|
local function update_group(group_record)
|
||||||
|
assert(not group_record.is_deleted)
|
||||||
|
|
||||||
|
-- Try find the group by id and then then name
|
||||||
|
local group = script_data.clusterio_id_to_group[group_record.id]
|
||||||
|
if not group then
|
||||||
|
group = game.permissions.get_group(group_record.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create a new group or update the found group
|
||||||
|
if not group or not group.valid then
|
||||||
|
group = assert(game.permissions.create_group(group_record.name))
|
||||||
|
else
|
||||||
|
group.name = group_record.name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update the permissions for the group
|
||||||
|
if group_record.permissions then
|
||||||
|
decode_group_permissions(group, group_record.permissions)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update the script data
|
||||||
|
script_data.factorio_to_clusterio_id[group.group_id] = group_record.id
|
||||||
|
script_data.clusterio_id_to_group[group_record.id] = group
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Delete the factorio permission group
|
||||||
|
--- @param group_record ExpPermissionGroups.GroupRecord
|
||||||
|
local function delete_group(group_record)
|
||||||
|
assert(group_record.is_deleted)
|
||||||
|
|
||||||
|
local default_group = get_default_group()
|
||||||
|
local group = script_data.clusterio_id_to_group[group_record.id]
|
||||||
|
if group then
|
||||||
|
move_players(group, default_group)
|
||||||
|
group.destroy()
|
||||||
|
end
|
||||||
|
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.is_deleted)
|
||||||
|
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
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.is_deleted)
|
||||||
|
|
||||||
|
local default_group = get_default_group()
|
||||||
|
local player = assert(game.get_player(assignment_record.name))
|
||||||
|
default_group.add_player(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Public methods
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Restore local references to persistent script data after load
|
||||||
|
function ExpGroups.on_load()
|
||||||
|
script_data = compat.script_data["exp_groups"]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Enable or disable emitting lua changes back to the instance plugin
|
||||||
|
--- @param enabled boolean?
|
||||||
|
function ExpGroups.set_emit_events(enabled)
|
||||||
|
script_data.emit_updates = enabled ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Replace local state with expected controller state on startup
|
||||||
|
--- @param group_records ExpPermissionGroups.GroupRecord[]
|
||||||
|
function ExpGroups.initialise_groups(group_records)
|
||||||
|
local _emit_events = script_data.emit_updates
|
||||||
|
script_data.emit_updates = false
|
||||||
|
|
||||||
|
-- Update all the received groups
|
||||||
|
local seen_clusterio_ids = {} --- @type table<number, boolean>
|
||||||
|
for _, group_record in pairs(group_records) do
|
||||||
|
update_group(group_record)
|
||||||
|
seen_clusterio_ids[group_record.id] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cleanup the script data, removing stale group ids
|
||||||
|
local factorio_to_clusterio_id = script_data.factorio_to_clusterio_id
|
||||||
|
local clusterio_id_to_group = script_data.clusterio_id_to_group
|
||||||
|
for factorio_id, clusterio_id in pairs(factorio_to_clusterio_id) do
|
||||||
|
if not seen_clusterio_ids[clusterio_id] then
|
||||||
|
factorio_to_clusterio_id[factorio_id] = nil
|
||||||
|
clusterio_id_to_group[clusterio_id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove all other groups
|
||||||
|
local default_group = get_default_group()
|
||||||
|
local default_group_id = default_group.group_id
|
||||||
|
for _, group in pairs(game.permissions.groups) do
|
||||||
|
if not factorio_to_clusterio_id[group.group_id] and group.group_id ~= default_group_id then
|
||||||
|
move_players(group, default_group)
|
||||||
|
group.destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
script_data.emit_updates = _emit_events
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Receive an updated version of a group record
|
||||||
|
--- @param group_record ExpPermissionGroups.GroupRecord
|
||||||
|
function ExpGroups.receive_group_update(group_record)
|
||||||
|
local _emit_events = script_data.emit_updates
|
||||||
|
script_data.emit_updates = false
|
||||||
|
|
||||||
|
if group_record.is_deleted then
|
||||||
|
delete_group(group_record)
|
||||||
|
else
|
||||||
|
update_group(group_record)
|
||||||
|
end
|
||||||
|
|
||||||
|
script_data.emit_updates = _emit_events
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Receive an updated version of a assignment record
|
||||||
|
--- @param assignment_record ExpPermissionGroups.AssignmentRecord
|
||||||
|
function ExpGroups.receive_assignment_update(assignment_record)
|
||||||
|
local _emit_events = script_data.emit_updates
|
||||||
|
script_data.emit_updates = false
|
||||||
|
|
||||||
|
if assignment_record.is_deleted then
|
||||||
|
delete_assignment(assignment_record)
|
||||||
|
else
|
||||||
|
update_assignment(assignment_record)
|
||||||
|
end
|
||||||
|
|
||||||
|
script_data.emit_updates = _emit_events
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the current script data for debugging purposes
|
||||||
|
--- @package
|
||||||
|
function ExpGroups._script_data()
|
||||||
|
return script_data
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
IPC events
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Emit a group update to the instance plugin
|
||||||
|
--- @param group LuaPermissionGroup
|
||||||
|
local function emit_group_update(group)
|
||||||
|
clusterio_api.send_json("exp_group:group_updated", {
|
||||||
|
group_name = group.name,
|
||||||
|
group_id = script_data.factorio_to_clusterio_id[group.group_id],
|
||||||
|
permissions = encode_group_permissions(group),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Emit a group deletion to the instance plugin
|
||||||
|
--- @param group_id number
|
||||||
|
--- @param group_name string
|
||||||
|
local function emit_group_delete(group_id, group_name)
|
||||||
|
clusterio_api.send_json("exp_group:group_deleted", {
|
||||||
|
group_name = group_name,
|
||||||
|
group_id = script_data.factorio_to_clusterio_id[group_id],
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Emit a player assignment to the instance plugin
|
||||||
|
--- @param assignments table<string, number>
|
||||||
|
local function emit_player_assignments(assignments)
|
||||||
|
clusterio_api.send_json("exp_group:player_assignments", {
|
||||||
|
assignments = assignments
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
IPC event queuing
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Mark a group as changed
|
||||||
|
--- @param group LuaPermissionGroup
|
||||||
|
local function mark_group_dirty(group)
|
||||||
|
script_data.dirty_groups[group.group_id] = group
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Mark a player as changed
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
local function mark_player_dirty(player)
|
||||||
|
script_data.dirty_players[player.name] = player
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Flush queued group updates to the instance plugin
|
||||||
|
local function flush_group_updates()
|
||||||
|
-- Check if updates should be updated
|
||||||
|
if not script_data.emit_updates then
|
||||||
|
script_data.dirty_groups = {}
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all the groups and emit their updates
|
||||||
|
for _, group in pairs(script_data.dirty_groups) do
|
||||||
|
if group.valid then
|
||||||
|
emit_group_update(group)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
script_data.dirty_groups = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Flush queued player updates to the instance plugin
|
||||||
|
local function flush_player_updates()
|
||||||
|
-- Check if updates should be updated
|
||||||
|
if not script_data.emit_updates then
|
||||||
|
script_data.dirty_players = {}
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Construct the update payload
|
||||||
|
local assignments = {} --- @type table<string, number>
|
||||||
|
for player_name, player in pairs(script_data.dirty_players) do
|
||||||
|
local group = player.valid and player.permission_group
|
||||||
|
if group then
|
||||||
|
assignments[player_name] = script_data.factorio_to_clusterio_id[group.group_id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
emit_player_assignments(assignments)
|
||||||
|
|
||||||
|
script_data.dirty_players = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Factorio events
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Handle clusterio server startup
|
||||||
|
local function on_server_startup()
|
||||||
|
setup_script_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle creation of permission groups
|
||||||
|
--- @param event EventData.on_permission_group_added
|
||||||
local function on_permission_group_added(event)
|
local function on_permission_group_added(event)
|
||||||
if not event.player_index then return end
|
-- Check if updates should be updated
|
||||||
pending_updates[event.group.name] = {
|
if not script_data.emit_updates then
|
||||||
created = true,
|
return
|
||||||
sync_all = true,
|
end
|
||||||
tick = event.tick,
|
|
||||||
permissions = {},
|
mark_group_dirty(event.group)
|
||||||
players = {},
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Handle deletion of permission groups
|
||||||
|
--- @param event EventData.on_permission_group_deleted
|
||||||
local function on_permission_group_deleted(event)
|
local function on_permission_group_deleted(event)
|
||||||
if not event.player_index then return end
|
-- Check if updates should be updated
|
||||||
local existing = pending_updates[event.group_name]
|
if not script_data.emit_updates then
|
||||||
pending_updates[event.group_name] = nil
|
return
|
||||||
if not existing or not existing.created then
|
|
||||||
clusterio_api.send_json("exp_groups-permission_group_delete", {
|
|
||||||
group = event.group_name,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
emit_group_delete(event.id, event.group_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Handle edits make to permission groups
|
||||||
|
--- @param event EventData.on_permission_group_edited
|
||||||
local function on_permission_group_edited(event)
|
local function on_permission_group_edited(event)
|
||||||
if not event.player_index then return end
|
-- Check if updates should be updated
|
||||||
local pending = pending_updates[event.group.name]
|
if not script_data.emit_updates then
|
||||||
if not pending then
|
return
|
||||||
pending = {
|
|
||||||
tick = event.tick,
|
|
||||||
permissions = {},
|
|
||||||
players = {},
|
|
||||||
}
|
|
||||||
pending_updates[event.group.name] = pending
|
|
||||||
end
|
end
|
||||||
pending.tick = event.tick
|
|
||||||
|
|
||||||
if event.type == "add-permission" then
|
-- Check if this is a player or group event
|
||||||
if not pending.sync_all then
|
if event.type == "add-player" or event.type == "remove-player" then
|
||||||
pending.permissions[event.action] = true
|
local player = assert(game.get_player(event.other_player_index))
|
||||||
end
|
mark_player_dirty(player)
|
||||||
elseif event.type == "remove-permission" then
|
else
|
||||||
if not pending.sync_all then
|
mark_group_dirty(event.group)
|
||||||
pending.permissions[event.action] = false
|
|
||||||
end
|
|
||||||
elseif event.type == "enable-all" then
|
|
||||||
pending.sync_all = true
|
|
||||||
elseif event.type == "disable-all" then
|
|
||||||
pending.sync_all = true
|
|
||||||
elseif event.type == "add-player" then
|
|
||||||
local player = game.get_player(event.other_player_index) --- @cast player -nil
|
|
||||||
pending.players[player.name] = true
|
|
||||||
elseif event.type == "remove-player" then
|
|
||||||
local player = game.get_player(event.other_player_index) --- @cast player -nil
|
|
||||||
pending.players[player.name] = nil
|
|
||||||
elseif event.type == "rename" then
|
|
||||||
pending.created = true
|
|
||||||
pending.sync_all = true
|
|
||||||
local old = pending_updates[event.old_name]
|
|
||||||
if old then pending.players = old.players end
|
|
||||||
on_permission_group_deleted{
|
|
||||||
tick = event.tick, player_index = event.player_index, group_name = event.old_name,
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function send_updates()
|
--- Periodically flush queued changes
|
||||||
local tick = game.tick - 600 -- 10 Seconds
|
local function on_nth_tick_flush()
|
||||||
local done = {}
|
flush_group_updates()
|
||||||
for group_name, pending in pairs(pending_updates) do
|
flush_player_updates()
|
||||||
if pending.tick < tick then
|
|
||||||
done[group_name] = true
|
|
||||||
if pending.sync_all then
|
|
||||||
clusterio_api.send_json("exp_groups-permission_group_create", {
|
|
||||||
group = group_name, defiantion = Groups.get_group(group_name):to_json(true),
|
|
||||||
})
|
|
||||||
else
|
|
||||||
if next(pending.players) then
|
|
||||||
clusterio_api.send_json("exp_groups-permission_group_edit", {
|
|
||||||
type = "assign_players", group = group_name, changes = table.get_keys(pending.players),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
local add, remove = {}, {}
|
|
||||||
for permission, state in pairs(pending.permissions) do
|
|
||||||
if state then
|
|
||||||
add[#add + 1] = permission
|
|
||||||
else
|
|
||||||
remove[#remove + 1] = permission
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if next(add) then
|
|
||||||
clusterio_api.send_json("exp_groups-permission_group_edit", {
|
|
||||||
type = "add_permissions", group = group_name, changes = Groups.actions_to_names(add),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
if next(remove) then
|
|
||||||
clusterio_api.send_json("exp_groups-permission_group_edit", {
|
|
||||||
type = "remove_permissions", group = group_name, changes = Groups.actions_to_names(remove),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for group_name in pairs(done) do
|
|
||||||
pending_updates[group_name] = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
local e = defines.events
|
||||||
events = {
|
|
||||||
[defines.events.on_permission_group_added] = on_permission_group_added,
|
local events = {
|
||||||
[defines.events.on_permission_group_deleted] = on_permission_group_deleted,
|
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||||
[defines.events.on_permission_group_edited] = on_permission_group_edited,
|
[e.on_multiplayer_init] = on_server_startup,
|
||||||
},
|
[e.on_permission_group_added] = on_permission_group_added,
|
||||||
on_nth_tick = {
|
[e.on_permission_group_deleted] = on_permission_group_deleted,
|
||||||
[300] = send_updates,
|
[e.on_permission_group_edited] = on_permission_group_edited,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local on_nth_tick = {
|
||||||
|
[300] = on_nth_tick_flush,
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpGroups.events = events --- @package
|
||||||
|
ExpGroups.on_nth_tick = on_nth_tick --- @package
|
||||||
|
return ExpGroups
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
--[[
|
||||||
|
It is best practice to not expose any globals because all modules share a global environment
|
||||||
|
However, sometimes you need globals, for example to access functions within rcon commands
|
||||||
|
Therefore, we advise that this should be the only file in your module to expose globals
|
||||||
|
Typically this would be your control file as shown in the example below
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- @diagnostic disable: global-element
|
||||||
|
|
||||||
|
-- Access using `/sc exp_groups.foo()`
|
||||||
|
exp_groups = require("modules/exp_groups/control")
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
"control.lua"
|
"control.lua"
|
||||||
],
|
],
|
||||||
"require": [
|
"require": [
|
||||||
|
"globals.lua"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clusterio": "*",
|
"clusterio": "*"
|
||||||
"exp_util": "*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +1,3 @@
|
|||||||
local Async = require("modules/exp_util/async")
|
|
||||||
|
|
||||||
local table_to_json = helpers.table_to_json
|
-- Access the exports from other modules using require("modules/exp_groups")
|
||||||
local json_to_table = helpers.json_to_table
|
return require("modules/exp_groups/control")
|
||||||
|
|
||||||
--- Top level module table, contains event handlers and public methods
|
|
||||||
local Groups = {}
|
|
||||||
|
|
||||||
--- @class ExpGroup : LuaPermissionGroup
|
|
||||||
--- @field group LuaPermissionGroup The permission group for this group proxy
|
|
||||||
Groups._prototype = {}
|
|
||||||
|
|
||||||
Groups._metatable = {
|
|
||||||
__index = setmetatable(Groups._prototype, {
|
|
||||||
--- @type any Annotation required because otherwise it is typed as 'table'
|
|
||||||
__index = function(self, key)
|
|
||||||
return self.group[key]
|
|
||||||
end,
|
|
||||||
}),
|
|
||||||
__class = "ExpGroup",
|
|
||||||
}
|
|
||||||
|
|
||||||
local action_to_name = {}
|
|
||||||
for name, action in pairs(defines.input_action) do
|
|
||||||
action_to_name[action] = name
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Async Functions
|
|
||||||
-- These are required to allow bypassing edit_permission_group
|
|
||||||
|
|
||||||
--- Add a player to a permission group, requires edit_permission_group
|
|
||||||
--- @param player LuaPlayer Player to add to the group
|
|
||||||
--- @param group LuaPermissionGroup Group to add the player to
|
|
||||||
--- @return boolean # True if successful
|
|
||||||
local function add_player_to_group(player, group)
|
|
||||||
return group.add_player(player)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Add a players to a permission group, requires edit_permission_group
|
|
||||||
--- @param players LuaPlayer[] Players to add to the group
|
|
||||||
--- @param group LuaPermissionGroup Group to add the players to
|
|
||||||
--- @return boolean # True if successful
|
|
||||||
local function add_players_to_group(players, group)
|
|
||||||
local add_player = group.add_player
|
|
||||||
if not add_player(players[1]) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
for i = 2, #players do
|
|
||||||
add_player(players[i])
|
|
||||||
end
|
|
||||||
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Async will bypass edit_permission_group but takes at least one tick
|
|
||||||
local add_player_to_group_async = Async.register(add_player_to_group)
|
|
||||||
local add_players_to_group_async = Async.register(add_players_to_group)
|
|
||||||
|
|
||||||
--- Static methods for gettings, creating and removing permission groups
|
|
||||||
|
|
||||||
--- Gets the permission group proxy with the given name or group ID.
|
|
||||||
--- @param group_name string|uint32 The name or id of the permission group
|
|
||||||
--- @return ExpGroup?
|
|
||||||
function Groups.get_group(group_name)
|
|
||||||
local group = game.permissions.get_group(group_name)
|
|
||||||
if group == nil then return nil end
|
|
||||||
return setmetatable({
|
|
||||||
group = group,
|
|
||||||
}, Groups._metatable)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Gets the permission group proxy for a players group
|
|
||||||
--- @param player LuaPlayer The player to get the group of
|
|
||||||
--- @return ExpGroup?
|
|
||||||
function Groups.get_player_group(player)
|
|
||||||
local group = player.permission_group
|
|
||||||
if group == nil then return nil end
|
|
||||||
return setmetatable({
|
|
||||||
group = group,
|
|
||||||
}, Groups._metatable)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Creates a new permission group, requires add_permission_group
|
|
||||||
--- @param group_name string Name of the group to create
|
|
||||||
--- @return ExpGroup
|
|
||||||
function Groups.new_group(group_name)
|
|
||||||
local group = game.permissions.get_group(group_name)
|
|
||||||
assert(group == nil, "Group already exists with name: " .. group_name)
|
|
||||||
group = game.permissions.create_group(group_name)
|
|
||||||
assert(group ~= nil, "Requires permission add_permission_group")
|
|
||||||
return setmetatable({
|
|
||||||
group = group,
|
|
||||||
}, Groups._metatable)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get or create a permisison group, must use the group name not the group id
|
|
||||||
--- @param group_name string Name of the group to create
|
|
||||||
--- @return ExpGroup
|
|
||||||
function Groups.get_or_create(group_name)
|
|
||||||
local group = game.permissions.get_group(group_name)
|
|
||||||
if group then
|
|
||||||
return setmetatable({
|
|
||||||
group = group,
|
|
||||||
}, Groups._metatable)
|
|
||||||
else
|
|
||||||
group = game.permissions.create_group(group_name)
|
|
||||||
assert(group ~= nil, "Requires permission add_permission_group")
|
|
||||||
return setmetatable({
|
|
||||||
group = group,
|
|
||||||
}, Groups._metatable)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Destory a permission group, moves all players to default group
|
|
||||||
--- @param group_name string|uint32 The name or id of the permission group to destroy
|
|
||||||
--- @param move_to_name string|uint32? The name or id of the permission group to move players to
|
|
||||||
function Groups.destroy_group(group_name, move_to_name)
|
|
||||||
local group = game.permissions.get_group(group_name)
|
|
||||||
if group == nil then return end
|
|
||||||
|
|
||||||
local players = group.players
|
|
||||||
if #players > 0 then
|
|
||||||
local move_to = game.permissions.get_group(move_to_name or "Default")
|
|
||||||
for _, player in ipairs(players) do
|
|
||||||
player.permission_group = move_to
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local success = group.destroy()
|
|
||||||
assert(success, "Requires permission delete_permission_group")
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Prototype methods for modifying and working with permission groups
|
|
||||||
|
|
||||||
--- Add a player to the permission group
|
|
||||||
--- @param player LuaPlayer The player to add to the group
|
|
||||||
function Groups._prototype:add_player(player)
|
|
||||||
if not add_player_to_group(player, self.group) then
|
|
||||||
add_player_to_group_async(player, self.group)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Add players to the permission group
|
|
||||||
--- @param players LuaPlayer[] The player to add to the group
|
|
||||||
function Groups._prototype:add_players(players)
|
|
||||||
if not add_players_to_group(players, self.group) then
|
|
||||||
add_players_to_group_async(players, self.group)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Move all players to another group
|
|
||||||
--- @param other_group ExpGroup The group to move players to, default is the Default group
|
|
||||||
function Groups._prototype:move_players(other_group)
|
|
||||||
if not add_players_to_group(self.group.players, other_group.group) then
|
|
||||||
add_players_to_group_async(self.group.players, other_group.group)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Allow a set of actions for this group
|
|
||||||
--- @param actions defines.input_action[] Actions to allow
|
|
||||||
--- @return ExpGroup
|
|
||||||
function Groups._prototype:allow_actions(actions)
|
|
||||||
local set_allow = self.group.set_allows_action
|
|
||||||
for _, action in ipairs(actions) do
|
|
||||||
set_allow(action, true)
|
|
||||||
end
|
|
||||||
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Disallow a set of actions for this group
|
|
||||||
--- @param actions defines.input_action[] Actions to disallow
|
|
||||||
--- @return ExpGroup
|
|
||||||
function Groups._prototype:disallow_actions(actions)
|
|
||||||
local set_allow = self.group.set_allows_action
|
|
||||||
for _, action in ipairs(actions) do
|
|
||||||
set_allow(action, false)
|
|
||||||
end
|
|
||||||
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Reset the allowed state of all actions
|
|
||||||
--- @param allowed boolean? default true for allow all actions, false to disallow all actions
|
|
||||||
--- @return ExpGroup
|
|
||||||
function Groups._prototype:reset(allowed)
|
|
||||||
local set_allow = self.group.set_allows_action
|
|
||||||
if allowed == nil then allowed = true end
|
|
||||||
for _, action in pairs(defines.input_action) do
|
|
||||||
set_allow(action, allowed)
|
|
||||||
end
|
|
||||||
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Returns if the group is allowed a given action
|
|
||||||
--- @param action string|defines.input_action Actions to test
|
|
||||||
--- @return boolean # True if successful
|
|
||||||
function Groups._prototype:allows(action)
|
|
||||||
if type(action) == "string" then
|
|
||||||
return self.group.allows_action(defines.input_action[action])
|
|
||||||
end
|
|
||||||
return self.group.allows_action(action)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Print a message to all players in the group
|
|
||||||
function Groups._prototype:print(...)
|
|
||||||
for _, player in ipairs(self.group.players) do
|
|
||||||
player.print(...)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Static and Prototype methods for use with IPC
|
|
||||||
|
|
||||||
--- Convert an array of strings into an array of action names
|
|
||||||
--- @param actions_names string[] An array of action names
|
|
||||||
--- @return defines.input_action[]
|
|
||||||
local function names_to_actions(actions_names)
|
|
||||||
--- @type defines.input_action[], number[], number
|
|
||||||
local actions, invalid, invalid_i = {}, {}, 1
|
|
||||||
for i, action_name in ipairs(actions_names) do
|
|
||||||
local action = defines.input_action[action_name] --[[ @as defines.input_action? ]]
|
|
||||||
if action then
|
|
||||||
actions[i] = action
|
|
||||||
else
|
|
||||||
invalid[invalid_i] = i
|
|
||||||
invalid_i = invalid_i + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local last = #actions
|
|
||||||
for _, i in ipairs(invalid) do
|
|
||||||
actions[i] = actions[last]
|
|
||||||
last = last - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
return actions
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get the action names from the action numbers
|
|
||||||
function Groups.actions_to_names(actions)
|
|
||||||
local names = {}
|
|
||||||
for i, action in ipairs(actions) do
|
|
||||||
names[i] = action_to_name[action]
|
|
||||||
end
|
|
||||||
|
|
||||||
return names
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get all input actions that are defined
|
|
||||||
function Groups.get_actions_json()
|
|
||||||
local rtn, rtn_i = {}, 1
|
|
||||||
for name in pairs(defines.input_action) do
|
|
||||||
rtn[rtn_i] = name
|
|
||||||
rtn_i = rtn_i + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
return table_to_json(rtn)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Convert a json string array into an array of input actions
|
|
||||||
--- @param json string A json string representing a string array of actions
|
|
||||||
--- @return defines.input_action[]
|
|
||||||
function Groups.json_to_actions(json)
|
|
||||||
local tbl = json_to_table(json)
|
|
||||||
assert(tbl, "Invalid Json String")
|
|
||||||
--- @cast tbl string[]
|
|
||||||
return names_to_actions(tbl)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Returns the shortest defination of the allowed actions
|
|
||||||
-- The first value of the return can be passed to :reset
|
|
||||||
function Groups._prototype:to_json(raw)
|
|
||||||
local allow, disallow = {}, {}
|
|
||||||
local allow_i, disallow_i = 1, 1
|
|
||||||
local allows = self.group.allows_action
|
|
||||||
for name, action in pairs(defines.input_action) do
|
|
||||||
if allows(action) then
|
|
||||||
allow[allow_i] = name
|
|
||||||
allow_i = allow_i + 1
|
|
||||||
else
|
|
||||||
disallow[disallow_i] = name
|
|
||||||
disallow_i = disallow_i + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if allow_i >= disallow_i then
|
|
||||||
return raw and { true, disallow } or table_to_json{ true, disallow }
|
|
||||||
end
|
|
||||||
return raw and { false, allow } or table_to_json{ false, allow }
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Restores this group to the state given in a json string
|
|
||||||
--- @param json string The json string to restore from
|
|
||||||
function Groups._prototype:from_json(json)
|
|
||||||
local tbl = json_to_table(json)
|
|
||||||
assert(tbl and type(tbl[1]) == "boolean" and type(tbl[2]) == "table", "Invalid Json String")
|
|
||||||
|
|
||||||
if tbl[1] then
|
|
||||||
self:reset(true):disallow_actions(names_to_actions(tbl[2]))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
self:reset(false):allow_actions(names_to_actions(tbl[2]))
|
|
||||||
end
|
|
||||||
|
|
||||||
return Groups
|
|
||||||
|
|||||||
+19
-11
@@ -1,41 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "@expcluster/permission_groups",
|
"name": "@expcluster/permission-groups",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
"description": "Clusterio plugin providing syncing of permission groups",
|
||||||
"description": "Example Description. Package. Change me in package.json",
|
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "explosivegaming/ExpCluster",
|
||||||
"main": "dist/node/index.js",
|
"main": "dist/node/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"$prepare": "tsc --build && webpack-cli --env production"
|
"prepare": "tsc --build && webpack-cli --env production"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/controller": "workspace:^",
|
||||||
|
"@clusterio/host": "workspace:^",
|
||||||
|
"@clusterio/lib": "workspace:^",
|
||||||
|
"@clusterio/web_ui": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@ant-design/icons": "^6.2.3",
|
||||||
"@clusterio/web_ui": "catalog:",
|
"@clusterio/controller": "workspace:^",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@clusterio/host": "workspace:^",
|
||||||
|
"@clusterio/lib": "workspace:^",
|
||||||
|
"@clusterio/web_ui": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"antd": "catalog:",
|
"antd": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
"react-router-dom": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
"webpack-cli": "catalog:",
|
"webpack-cli": "catalog:",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-merge": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "catalog:"
|
||||||
"fs-extra": "^11.3.3"
|
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"clusterio",
|
"clusterio",
|
||||||
|
"clusterio-plugin",
|
||||||
"factorio"
|
"factorio"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.browser.json" },
|
{ "path": "./tsconfig.node.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.browser.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 }: {
|
||||||
|
open: boolean,
|
||||||
|
setOpen: (open: boolean) => void,
|
||||||
|
initial?: messages.AssignmentRecord,
|
||||||
|
}) {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as WebPlugin;
|
||||||
|
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
const [users] = useUsers();
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
function submit(values: any) {
|
||||||
|
if (initial) {
|
||||||
|
control.send(new messages.AssignmentUpdateRequest(
|
||||||
|
new messages.AssignmentRecord(
|
||||||
|
values.name,
|
||||||
|
values.groupId,
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
control.send(new messages.AssignmentCreateRequest(
|
||||||
|
values.name,
|
||||||
|
values.groupId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} onFinish={submit}>
|
||||||
|
<Form.Item name="name" label="Player" rules={[{ required: true }]}>
|
||||||
|
<Select disabled={Boolean(initial)} showSearch>
|
||||||
|
{[...users.values()].map(u => (
|
||||||
|
<Select.Option key={u.name} value={u.name}>
|
||||||
|
{u.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
{[...groups.values()].map(g => (
|
||||||
|
<Select.Option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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 { 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 WebPlugin;
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const searchInput = useRef<InputRef>(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: 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",
|
||||||
|
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",
|
||||||
|
width: "10%",
|
||||||
|
render: (_: any, record: AssignmentRecord) => (
|
||||||
|
<Space>
|
||||||
|
{account.hasPermission("exp_groups.assignment.update") &&
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => {
|
||||||
|
setEditing(record);
|
||||||
|
setOpen(true);
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
{account.hasPermission("exp_groups.assignment.delete") && <DeletedConfirm
|
||||||
|
onConfirm={() => control.send(new AssignmentDeleteRequest(record.name))}
|
||||||
|
/>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}] : []),
|
||||||
|
]}
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 }: {
|
||||||
|
open: boolean,
|
||||||
|
setOpen: (open: boolean) => void,
|
||||||
|
}) {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button, Checkbox, Input, Space, Spin, Alert } from "antd";
|
||||||
|
|
||||||
|
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_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"],
|
||||||
|
"Blueprints": ["blueprint", "copy", "paste", "undo", "redo", "upgrade"],
|
||||||
|
"Trains": ["train", "station", "schedule", "interrupt", "rolling_stock"],
|
||||||
|
"Circuit Network": ["combinator", "circuit", "signal", "wire", "cable", "speaker", "display_panel"],
|
||||||
|
"GUI": ["gui", "open"],
|
||||||
|
"Pins & Alerts": ["pin", "alert", "tag"],
|
||||||
|
"Logistics": ["logistic", "filter", "request"],
|
||||||
|
"Space": ["space", "surface", "planet", "rocket"],
|
||||||
|
"Toggles": ["toggle", "switch", "swap", "set"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDomain(name: string) {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
|
||||||
|
for (const [domain, keywords] of Object.entries(DOMAIN_MAPPING)) {
|
||||||
|
if (keywords.some(k => n.includes(k))) return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupViewPage() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as WebPlugin;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const { id } = useParams();
|
||||||
|
const groupId = Number(id);
|
||||||
|
|
||||||
|
const [groups, synced] = plugin.useGroups();
|
||||||
|
const group = groups.get(groupId);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const defaultModPack = useDefaultModPack();
|
||||||
|
const [definesJson, setDefinesJson] = useState<Record<string, number> | null>(null);
|
||||||
|
|
||||||
|
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
|
||||||
|
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
|
||||||
|
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(() => {
|
||||||
|
if (!defaultModPack?.exportManifest?.assets?.defines) return;
|
||||||
|
if (definesJson) return;
|
||||||
|
|
||||||
|
const assetName = defaultModPack.exportManifest.assets.defines;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group || !definesJson) return;
|
||||||
|
if (Object.keys(permissionState).length) return;
|
||||||
|
|
||||||
|
const { isBlacklist, permissions } = group.permissions;
|
||||||
|
|
||||||
|
const base = Object.fromEntries(
|
||||||
|
Object.keys(definesJson).map(p => [
|
||||||
|
p, isBlacklist !== permissions.includes(p)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
setPermissionState(base);
|
||||||
|
setBaselineState(base);
|
||||||
|
}, [group, definesJson]);
|
||||||
|
|
||||||
|
// sync name
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) setName(group.name);
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
// derived data
|
||||||
|
const allPermissions = useMemo(() => (
|
||||||
|
Object.keys(definesJson ?? {})
|
||||||
|
), [definesJson]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => allPermissions.filter(p =>
|
||||||
|
p.toLowerCase().includes(search.toLowerCase())
|
||||||
|
), [allPermissions, search]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const p of filtered) {
|
||||||
|
const domain = getDomain(p);
|
||||||
|
if (!map.has(domain)) map.set(domain, []);
|
||||||
|
map.get(domain)!.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
for (const [, perms] of sorted) {
|
||||||
|
perms.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
const permissionChanged = Object.keys(permissionState).some(
|
||||||
|
k => permissionState[k] !== baselineState[k]
|
||||||
|
);
|
||||||
|
const metaChanged = group ? name !== group.name : false;
|
||||||
|
const edited = permissionChanged || metaChanged;
|
||||||
|
|
||||||
|
function applyChanges() {
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const whitelist: string[] = [];
|
||||||
|
const blacklist: string[] = [];
|
||||||
|
|
||||||
|
for (const [perm, enabled] of Object.entries(permissionState)) {
|
||||||
|
if (enabled) whitelist.push(perm);
|
||||||
|
else blacklist.push(perm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBlacklist = blacklist.length < whitelist.length;
|
||||||
|
const permissions = isBlacklist ? blacklist : whitelist;
|
||||||
|
|
||||||
|
control.send(new messages.GroupUpdateRequest(
|
||||||
|
new messages.GroupRecord(group.id, name, new messages.GroupPermissions(isBlacklist, permissions))
|
||||||
|
))
|
||||||
|
.then(() => setBaselineState(permissionState))
|
||||||
|
.catch(notifyErrorHandler("Error applying changes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertChanges() {
|
||||||
|
if (!group) return;
|
||||||
|
setPermissionState(baselineState);
|
||||||
|
setName(group.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!synced) {
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
|
||||||
|
<Spin />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
|
||||||
|
<PageHeader title="Group not found" />
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
background: "#2a1912",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<span>You have unsaved changes</span>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={revertChanges}>Revert</Button>
|
||||||
|
<Button type="primary" onClick={applyChanges}>Apply</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16, display: "flex", gap: 12 }}>
|
||||||
|
<label style={{ width: 110 }}>Name</label>
|
||||||
|
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Permissions</h3>
|
||||||
|
|
||||||
|
{!defaultModPack?.exportManifest?.assets?.defines && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="Missing export"
|
||||||
|
description="An export of the default mod pack is required to modify permissions."
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{defaultModPack?.exportManifest?.assets?.defines && !definesJson && <Spin />}
|
||||||
|
|
||||||
|
{definesJson && <>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Search permissions"
|
||||||
|
allowClear
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{grouped.map(([groupName, permissions]) => (
|
||||||
|
<div key={groupName} style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
|
||||||
|
|
||||||
|
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||||
|
<strong>{groupName}</strong>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
setPermissionState(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const p of permissions) next[p] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
setPermissionState(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const p of permissions) next[p] = false;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{permissions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p}
|
||||||
|
checked={permissionState[p]}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onChange={e =>
|
||||||
|
setPermissionState(prev => ({ ...prev, [p]: e.target.checked }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</>}
|
||||||
|
</PageLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { Table } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ControlContext } from "@clusterio/web_ui";
|
||||||
|
import { GroupRecord } from "../../messages";
|
||||||
|
import type { WebPlugin } from "..";
|
||||||
|
|
||||||
|
import GroupForm from "./GroupForm";
|
||||||
|
|
||||||
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||||
|
|
||||||
|
export default function GroupsTable() {
|
||||||
|
const control = useContext(ControlContext);
|
||||||
|
const plugin = control.plugins.get("exp_groups") as WebPlugin;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [groups, synced] = plugin.useGroups();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
dataIndex: "name",
|
||||||
|
sorter: (a: any, b: GroupRecord) => strcmp(a.name, b.name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Permissions",
|
||||||
|
width: "50%",
|
||||||
|
render: (_: any, g: GroupRecord) => (
|
||||||
|
`${g.permissions.permissions.length} ${g.permissions.isBlacklist ? "Disallowed" : "Allowed"}`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={[...groups.values()]}
|
||||||
|
loading={!synced}
|
||||||
|
rowKey={(g) => g.id}
|
||||||
|
pagination={false}
|
||||||
|
onRow={(g) => ({
|
||||||
|
onClick: () => navigate(`/permission_groups/${g.id}/view`),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupForm open={open} setOpen={setOpen} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useContext, useEffect } from "react";
|
||||||
|
import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd";
|
||||||
|
|
||||||
|
import { ControlContext, useRoles } from "@clusterio/web_ui";
|
||||||
|
import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages";
|
||||||
|
import type { WebPlugin } from "..";
|
||||||
|
|
||||||
|
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 WebPlugin;
|
||||||
|
|
||||||
|
const [groups] = plugin.useGroups();
|
||||||
|
const [roles] = useRoles();
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
function submit(values: any) {
|
||||||
|
if (initial) {
|
||||||
|
control.send(new RoleMappingUpdateRequest(
|
||||||
|
new RoleMappingRecord(
|
||||||
|
initial.id,
|
||||||
|
new Set(values.roleIds),
|
||||||
|
values.groupId,
|
||||||
|
values.priority,
|
||||||
|
values.enabled,
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
control.send(new RoleMappingCreateRequest(
|
||||||
|
values.roleIds,
|
||||||
|
values.groupId,
|
||||||
|
values.priority,
|
||||||
|
values.enabled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
<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 => (
|
||||||
|
<Select.Option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Form.Item name="enabled" label="Enabled" valuePropName="checked" initialValue={true}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { Table, Button, Space, Tag } from "antd";
|
||||||
|
import { EditOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui";
|
||||||
|
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 WebPlugin;
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
|
const [roleMappings, roleMappingsSynced] = plugin.useRoleMappings();
|
||||||
|
const [groups, groupsSynced] = plugin.useGroups();
|
||||||
|
const [roles, rolesSynced] = useRoles();
|
||||||
|
|
||||||
|
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",
|
||||||
|
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",
|
||||||
|
width: "30%",
|
||||||
|
filters: groupFilters,
|
||||||
|
onFilter: (value, record: RoleMappingRecord) => (
|
||||||
|
record.groupId === value
|
||||||
|
),
|
||||||
|
render: (_: any, record: RoleMappingRecord) => (
|
||||||
|
groups.get(record.groupId)?.name ?? record.groupId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Enabled",
|
||||||
|
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={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} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Tree } from 'antd';
|
|
||||||
import type { TreeDataNode, TreeProps } from 'antd';
|
|
||||||
|
|
||||||
const defaultData: TreeDataNode[] = [
|
|
||||||
{
|
|
||||||
title: "Group 1",
|
|
||||||
key: "G-1",
|
|
||||||
icon: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: "Role 1",
|
|
||||||
key: "R-1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Role 2",
|
|
||||||
key: "R-2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Role 3",
|
|
||||||
key: "R-3"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Group 2",
|
|
||||||
key: "G-2",
|
|
||||||
icon: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: "Role 4",
|
|
||||||
key: "R-4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Role 5",
|
|
||||||
key: "R-5"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Default",
|
|
||||||
key: "G-3",
|
|
||||||
icon: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: "Role 6",
|
|
||||||
key: "R-6"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GroupTree() {
|
|
||||||
const [gData, setGData] = useState(defaultData);
|
|
||||||
|
|
||||||
const onDrop: TreeProps['onDrop'] = (info) => {
|
|
||||||
const dropKey = info.node.key;
|
|
||||||
const dragKey = info.dragNode.key;
|
|
||||||
const dropPos = info.node.pos.split('-');
|
|
||||||
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); // the drop position relative to the drop node, inside 0, top -1, bottom 1
|
|
||||||
|
|
||||||
const findKey = (
|
|
||||||
data: TreeDataNode[],
|
|
||||||
key: React.Key,
|
|
||||||
callback: (node: TreeDataNode, i: number, data: TreeDataNode[]) => void,
|
|
||||||
) => {
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
if (data[i].key === key) {
|
|
||||||
return callback(data[i], i, data);
|
|
||||||
}
|
|
||||||
if (data[i].children) {
|
|
||||||
findKey(data[i].children!, key, callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = [...gData]
|
|
||||||
|
|
||||||
// Find dragObject
|
|
||||||
let dragObj: TreeDataNode;
|
|
||||||
findKey(data, dragKey, (item, index, arr) => {
|
|
||||||
arr.splice(index, 1);
|
|
||||||
dragObj = item;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!info.dropToGap) {
|
|
||||||
// Drop on the content
|
|
||||||
findKey(data, dropKey, (item) => {
|
|
||||||
item.children = item.children || [];
|
|
||||||
// where to insert. New item was inserted to the start of the array in this example, but can be anywhere
|
|
||||||
item.children.unshift(dragObj);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let ar: TreeDataNode[] = [];
|
|
||||||
let i: number;
|
|
||||||
findKey(data, dropKey, (_item, index, arr) => {
|
|
||||||
ar = arr;
|
|
||||||
i = index;
|
|
||||||
});
|
|
||||||
if (dropPosition === -1) {
|
|
||||||
// Drop on the top of the drop node
|
|
||||||
ar.splice(i!, 0, dragObj!);
|
|
||||||
} else {
|
|
||||||
// Drop on the bottom of the drop node
|
|
||||||
ar.splice(i! + 1, 0, dragObj!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setGData(data)
|
|
||||||
};
|
|
||||||
|
|
||||||
const allowDrop: TreeProps['allowDrop'] = ({dragNode, dropNode, dropPosition}) => {
|
|
||||||
const dragType = (dragNode.key as string).charAt(0);
|
|
||||||
const dropType = (dropNode.key as string).charAt(0);
|
|
||||||
return dropType === dragType && dropPosition != 0 || dragType === "R" && dropType === "G" && dropPosition == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tree
|
|
||||||
className="draggable-tree"
|
|
||||||
defaultExpandAll={true}
|
|
||||||
draggable
|
|
||||||
blockNode
|
|
||||||
onDrop={onDrop}
|
|
||||||
allowDrop={allowDrop}
|
|
||||||
treeData={gData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+83
-57
@@ -1,82 +1,108 @@
|
|||||||
import React, {
|
import React, { useState, useCallback, useSyncExternalStore } from "react";
|
||||||
useContext, useEffect, useState,
|
import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui";
|
||||||
useCallback, useSyncExternalStore,
|
import { Button } from "antd";
|
||||||
} from "react";
|
|
||||||
|
|
||||||
// import {
|
|
||||||
//
|
|
||||||
// } from "antd";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
|
||||||
useInstances,
|
|
||||||
} from "@clusterio/web_ui";
|
|
||||||
|
|
||||||
import { PermissionGroupUpdate, PermissionInstanceId, PermissionStringsUpdate } from "../messages";
|
|
||||||
|
|
||||||
|
import * as messages from "../messages";
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
import { GroupTree } from "./components/groupTree";
|
import GroupsTable from "./components/GroupsTable";
|
||||||
|
import AssignmentsTable from "./components/AssignmentsTable";
|
||||||
|
import RoleMappingsTable from "./components/RoleMappingsTable";
|
||||||
|
import GroupViewPage from "./components/GroupViewPage";
|
||||||
|
import GroupForm from "./components/GroupForm";
|
||||||
|
import RoleMappingForm from "./components/RoleMappingForm";
|
||||||
|
import AssignmentForm from "./components/AssignmentForm";
|
||||||
|
|
||||||
function MyTemplatePage() {
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const plugin = control.plugins.get("exp_groups") as WebPlugin;
|
|
||||||
const [permissionStrings, permissionStringsSynced] = plugin.usePermissionStrings();
|
|
||||||
const [permissionGroups, permissionGroupsSynced] = plugin.usePermissionGroups();
|
|
||||||
const [instances, instancesSync] = useInstances();
|
|
||||||
|
|
||||||
let [roles, setRoles] = useState<lib.Role[]>([]);
|
function ExpGroupsPage() {
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
useEffect(() => {
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
control.send(new lib.RoleListRequest()).then(newRoles => {
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
setRoles(newRoles);
|
const [roleMappingOpen, setRoleMappingOpen] = useState(false);
|
||||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <PageLayout nav={[{ name: "exp_groups" }]}>
|
return <PageLayout nav={[{ name: "Permission Groups" }]}>
|
||||||
<PageHeader title="exp_groups" />
|
<PageHeader title="Permission Groups" />
|
||||||
Permission Strings: {String(permissionStringsSynced)} {JSON.stringify([...permissionStrings.values()])} <br/>
|
|
||||||
Permission Groups: {String(permissionGroupsSynced)} {JSON.stringify([...permissionGroups.values()])} <br/>
|
{account.hasPermission("exp_groups.group.list") && <>
|
||||||
Instances: {String(instancesSync)} {JSON.stringify([...instances.values()].map(instance => [instance.id, instance.name]))} <br/>
|
<SectionHeader
|
||||||
Roles: {JSON.stringify([...roles.values()].map(role => [role.id, role.name]))} <br/>
|
title="Groups"
|
||||||
<GroupTree/>
|
extra={
|
||||||
|
account.hasPermission("exp_groups.group.create")
|
||||||
|
? <Button type="primary" onClick={() => setGroupOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<GroupsTable />
|
||||||
|
<GroupForm open={groupOpen} setOpen={setGroupOpen} />
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{account.hasPermission("exp_groups.role_mapping.list") && <>
|
||||||
|
<SectionHeader
|
||||||
|
title="Role Mappings"
|
||||||
|
extra={
|
||||||
|
account.hasPermission("exp_groups.role_mapping.create")
|
||||||
|
? <Button type="primary" onClick={() => setRoleMappingOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RoleMappingsTable />
|
||||||
|
<RoleMappingForm open={roleMappingOpen} setOpen={setRoleMappingOpen} />
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{account.hasPermission("exp_groups.assignment.list") && <>
|
||||||
|
<SectionHeader
|
||||||
|
title="Assignments"
|
||||||
|
extra={
|
||||||
|
account.hasPermission("exp_groups.assignment.create")
|
||||||
|
? <Button type="primary" onClick={() => setAssignmentOpen(true)}>Create</Button>
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AssignmentsTable />
|
||||||
|
<AssignmentForm open={assignmentOpen} setOpen={setAssignmentOpen} />
|
||||||
|
</>}
|
||||||
</PageLayout>;
|
</PageLayout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebPlugin extends BaseWebPlugin {
|
export class WebPlugin extends BaseWebPlugin {
|
||||||
permissionStrings = new lib.EventSubscriber(PermissionStringsUpdate, this.control);
|
groups = new lib.EventSubscriber(messages.GroupUpdatedEvent, this.control);
|
||||||
permissionGroups = new lib.EventSubscriber(PermissionGroupUpdate, this.control);
|
assignments = new lib.EventSubscriber(messages.ManualAssignmentUpdatedEvent, this.control);
|
||||||
|
roleMappings = new lib.EventSubscriber(messages.RoleMappingUpdatedEvent, this.control);
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.pages = [
|
this.pages = [
|
||||||
{
|
{
|
||||||
path: "/exp_groups",
|
path: "/permission_groups",
|
||||||
sidebarName: "exp_groups",
|
sidebarName: "Permission Groups",
|
||||||
permission: "exp_groups.list",
|
permission: (account => account.hasAnyPermission(
|
||||||
content: <MyTemplatePage/>,
|
"exp_groups.group.list",
|
||||||
|
"exp_groups.assignment.list",
|
||||||
|
"exp_groups.role_mapping.list",
|
||||||
|
)),
|
||||||
|
content: <ExpGroupsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/permission_groups/:id/view",
|
||||||
|
sidebarPath: "/permission_groups",
|
||||||
|
permission: "exp_groups.group.get",
|
||||||
|
content: <GroupViewPage />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
useInstancePermissionStrings(instanceId?: PermissionInstanceId) {
|
useGroups() {
|
||||||
const [permissionStrings, synced] = this.usePermissionStrings();
|
const subscribe = useCallback((cb: () => void) => this.groups.subscribe(cb), []);
|
||||||
return [instanceId !== undefined ? permissionStrings.get(instanceId) : undefined, synced] as const;
|
return useSyncExternalStore(subscribe, () => this.groups.getSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
usePermissionStrings() {
|
useAssignments() {
|
||||||
const control = useContext(ControlContext);
|
const subscribe = useCallback((cb: () => void) => this.assignments.subscribe(cb), []);
|
||||||
const subscribe = useCallback((callback: () => void) => this.permissionStrings.subscribe(callback), [control]);
|
return useSyncExternalStore(subscribe, () => this.assignments.getSnapshot());
|
||||||
return useSyncExternalStore(subscribe, () => this.permissionStrings.getSnapshot());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useInstancePermissionGroups(instanceId?: PermissionInstanceId) {
|
useRoleMappings() {
|
||||||
const [permissionGroups, synced] = this.usePermissionGroups();
|
const subscribe = useCallback((cb: () => void) => this.roleMappings.subscribe(cb), []);
|
||||||
return [instanceId !== undefined ? [...permissionGroups.values()].filter(group => group.instanceId === instanceId) : undefined, synced] as const;
|
return useSyncExternalStore(subscribe, () => this.roleMappings.getSnapshot());
|
||||||
}
|
|
||||||
|
|
||||||
usePermissionGroups() {
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const subscribe = useCallback((callback: () => void) => this.permissionGroups.subscribe(callback), [control]);
|
|
||||||
return useSyncExternalStore(subscribe, () => this.permissionGroups.getSnapshot());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ module.exports = (env = {}) => merge(common(env), {
|
|||||||
"antd": { import: false },
|
"antd": { import: false },
|
||||||
"react": { import: false },
|
"react": { import: false },
|
||||||
"react-dom": { import: false },
|
"react-dom": { import: false },
|
||||||
|
"react-router": { import: false },
|
||||||
|
"react-router-dom": { import: false },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||||
|
|
||||||
export class ControllerPlugin extends BaseControllerPlugin {
|
export class ControllerPlugin extends BaseControllerPlugin {
|
||||||
async init() {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@clusterio/web_ui": "catalog:",
|
"@clusterio/web_ui": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"antd": "catalog:",
|
"antd": "catalog:",
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useContext, useEffect, useState,
|
|
||||||
useCallback, useSyncExternalStore,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
// import {
|
|
||||||
//
|
|
||||||
// } from "antd";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
|
||||||
} from "@clusterio/web_ui";
|
|
||||||
|
|
||||||
import {
|
|
||||||
PluginExampleEvent, PluginExampleRequest,
|
|
||||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
|
||||||
} from "../messages";
|
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
|
||||||
|
|
||||||
function MyTemplatePage() {
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const plugin = control.plugins.get("exp_scenario") as WebPlugin;
|
|
||||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
|
||||||
|
|
||||||
return <PageLayout nav={[{ name: "exp_scenario" }]}>
|
|
||||||
<PageHeader title="exp_scenario" />
|
|
||||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
|
||||||
</PageLayout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WebPlugin extends BaseWebPlugin {
|
|
||||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.pages = [
|
|
||||||
{
|
|
||||||
path: "/exp_scenario",
|
|
||||||
sidebarName: "exp_scenario",
|
|
||||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
|
||||||
// An undefined value means that the page will always be visible
|
|
||||||
permission: "exp_scenario.example.permission.subscribe",
|
|
||||||
content: <MyTemplatePage/>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
|
||||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
useSubscribableData() {
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
|
|
||||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
|
||||||
this.logger.info(JSON.stringify(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
|
||||||
this.logger.info(JSON.stringify(request));
|
|
||||||
return {
|
|
||||||
myResponseString: request.myString,
|
|
||||||
myResponseNumbers: request.myNumberArray,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@expcluster/server_ups",
|
"name": "@expcluster/server-ups",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Clusterio plugin providing in game server ups counter",
|
"description": "Clusterio plugin providing in game server ups counter",
|
||||||
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
||||||
@@ -13,12 +13,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@clusterio/lib": "workspace:^",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@clusterio/lib": "catalog:",
|
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
"webpack-cli": "catalog:",
|
"webpack-cli": "catalog:",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-merge": "catalog:"
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/lib": "catalog:"
|
"@clusterio/lib": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/lib": "catalog:",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"webpack": "catalog:",
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "root",
|
|
||||||
"private": true,
|
|
||||||
"files": [],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc --build",
|
|
||||||
"watch": "tsc --build --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "catalog:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-3782
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
packages:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
catalog:
|
|
||||||
'@clusterio/lib': 2.0.0-alpha.22b
|
|
||||||
'@clusterio/web_ui': 2.0.0-alpha.22b
|
|
||||||
'@sinclair/typebox': ^0.30.4
|
|
||||||
'@types/node': ^20.19.29
|
|
||||||
'@types/react': 18.2.0
|
|
||||||
antd: 5.24.2
|
|
||||||
react: 18.2.0
|
|
||||||
react-dom: 18.2.0
|
|
||||||
typescript: ^5.9.3
|
|
||||||
webpack: 5.98.0
|
|
||||||
webpack-cli: 5.1.4
|
|
||||||
webpack-merge: 5.9.0
|
|
||||||
Reference in New Issue
Block a user