Merge pull request #419 from Cooldude2606/plugin/groups

Add plugin for permission group syncing
This commit is contained in:
Cooldude2606
2026-05-27 00:41:18 +01:00
committed by GitHub
33 changed files with 2794 additions and 5207 deletions
-1
View File
@@ -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
+2 -2
View File
@@ -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:",
+375 -223
View File
@@ -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>;
userToGroup: Map<lib.User["id"], PermissionGroup> = new Map(); // TODO this needs to be per instance resolvedAssignments!: lib.SubscribableDatastore<messages.AssignmentRecord>;
permissionStrings!: Map<PermissionStrings["id"], PermissionStrings>;
permissionGroups!: Map<InstancePermissionGroups["id"], InstancePermissionGroups>;
async init() { async init() {
this.controller.handle(PermissionStringsUpdate, this.handlePermissionStringsUpdate.bind(this)); const databaseDirectory = this.controller.config.get("controller.database_directory");
this.controller.handle(PermissionGroupUpdate, this.handlePermissionGroupUpdate.bind(this));
this.controller.handle(PermissionGroupEditEvent, this.handlePermissionGroupEditEvent.bind(this));
this.controller.subscriptions.handle(PermissionStringsUpdate, this.handlePermissionStringsSubscription.bind(this));
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.groups = 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", "groups.json"),
const groups = instanceGroups.groups; messages.GroupRecord.fromJSON.bind(messages.GroupRecord),
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) { this.roleMappings = new lib.SubscribableDatastore(
for (const role of missingRoles) { ...await new lib.JsonIdDatastoreProvider(
defaultGroup.roleIds.add(role.id) path.join(databaseDirectory, "exp_groups", "role_mappings.json"),
messages.RoleMappingRecord.fromJSON.bind(messages.RoleMappingRecord),
).bootstrap()
);
this.manualAssignments = new lib.SubscribableDatastore(
...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_groups", "assignments.json"),
messages.AssignmentRecord.fromJSON.bind(messages.AssignmentRecord),
).bootstrap()
);
this.resolvedAssignments = new lib.SubscribableDatastore();
this.controller.subscriptions.handle(messages.GroupUpdatedEvent, this.handleGroupSubscription.bind(this));
this.controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, this.handleRoleMappingSubscription.bind(this));
this.controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, this.handleManualAssignmentSubscription.bind(this));
this.controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentSubscription.bind(this));
this.groups.on("update", this.groupsUpdated.bind(this));
this.roleMappings.on("update", this.roleMappingsUpdated.bind(this));
this.manualAssignments.on("update", this.manualAssignmentsUpdated.bind(this));
this.resolvedAssignments.on("update", this.resolvedAssignmentsUpdated.bind(this));
this.controller.handle(messages.GroupCreateRequest, this.handleGroupCreateRequest.bind(this));
this.controller.handle(messages.GroupUpdateRequest, this.handleGroupUpdateRequest.bind(this));
this.controller.handle(messages.GroupDeleteRequest, this.handleGroupDeleteRequest.bind(this));
this.controller.handle(messages.GroupGetRequest, this.handleGroupGetRequest.bind(this));
this.controller.handle(messages.GroupListRequest, this.handleGroupListRequest.bind(this));
this.controller.handle(messages.AssignmentCreateRequest, this.handleAssignmentCreateRequest.bind(this));
this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this));
this.controller.handle(messages.AssignmentDeleteRequest, this.handleAssignmentDeleteRequest.bind(this));
this.controller.handle(messages.AssignmentGetRequest, this.handleAssignmentGetRequest.bind(this));
this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this));
this.controller.handle(messages.RoleMappingCreateRequest, this.handleRoleMappingCreateRequest.bind(this));
this.controller.handle(messages.RoleMappingUpdateRequest, this.handleRoleMappingUpdateRequest.bind(this));
this.controller.handle(messages.RoleMappingDeleteRequest, this.handleRoleMappingDeleteRequest.bind(this));
this.controller.handle(messages.RoleMappingGetRequest, this.handleRoleMappingGetRequest.bind(this));
this.controller.handle(messages.RoleMappingListRequest, this.handleRoleMappingListRequest.bind(this));
} }
} else {
groups.set("Default", new PermissionGroup( async onShutdown() {
instanceGroups.instanceId, await Promise.all([
"Default", this.groups.save(),
groups.size, this.manualAssignments.save(),
new Set(missingRoles.map(role => role.id)) this.roleMappings.save(),
)); ])
} }
/*
Subscriptions
*/
async groupsUpdated(groups: messages.GroupRecord[]) {
this.controller.subscriptions.broadcast(new messages.GroupUpdatedEvent(groups));
// We need to do extra work if the group was deleted
const deletedGroupIds = groups.filter(g => g.isDeleted).map(g => g.id);
if (!deletedGroupIds.length) {
return;
}
// Cascade the delete down to affected role mappings
const mappingsToDelete = [];
for (const mapping of this.roleMappings.values()) {
if (deletedGroupIds.includes(mapping.groupId)) {
mappingsToDelete.push(mapping);
}
}
if (mappingsToDelete.length) {
this.roleMappings.deleteMany(mappingsToDelete);
}
// Cascade the delete down to affected manual assignments
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);
}
// Find all the affected players who were assigned to this group
for (const resolved of this.resolvedAssignments.values()) {
if (deletedGroupIds.includes(resolved.groupId)) {
affectedPlayers.add(resolved.name);
}
}
if (affectedPlayers.size) {
this.resolvedAssignments.setMany(await this.computeResolvedAssignments([...affectedPlayers]));
} }
} }
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { async handleGroupSubscription(request: lib.SubscriptionRequest) {
if (field === "exp_groups.allow_role_inconsistency") { const groups = [...this.groups.values()]
// Do something with this.userToGroup .filter(group => group.updatedAtMs > request.lastRequestTimeMs);
return groups.length ? new messages.GroupUpdatedEvent(groups) : null;
}
async roleMappingsUpdated(roleMappings: messages.RoleMappingRecord[]) {
this.controller.subscriptions.broadcast(new messages.RoleMappingUpdatedEvent(roleMappings));
// Mappings pointing to a deleted group have already been handled
// But if any are active, then we still must recompute all assignments
let hasActiveGroup = false;
for (const roleMapping of roleMappings) {
const group = this.groups.get(roleMapping.groupId);
if (group && !group.isDeleted) {
hasActiveGroup = true;
break;
}
}
if (!hasActiveGroup) {
return;
}
// 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 onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { async handleRoleMappingSubscription(request: lib.SubscriptionRequest) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); const mappings = [...this.roleMappings.values()]
if (field === "exp_groups.sync_permission_groups") { .filter(mapping => mapping.updatedAtMs > request.lastRequestTimeMs);
const updates = [] return mappings.length ? new messages.RoleMappingUpdatedEvent(mappings) : null;
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);
} }
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);
} }
} else { if (affectedPlayers.length) {
// Global sync disabled, make a copy of the global config as a base this.resolvedAssignments.setMany(await this.computeResolvedAssignments(affectedPlayers));
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() { async handleManualAssignmentSubscription(request: lib.SubscriptionRequest) {
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath); const assignments = [...this.resolvedAssignments.values()]
this.logger.verbose(`Loading ${file}`); .filter(a => a.updatedAtMs > request.lastRequestTimeMs);
try { return assignments.length ? new messages.ManualAssignmentUpdatedEvent(assignments) : null;
const content = await fs.readFile(file, { encoding: "utf8" }); }
for (const groupRaw of JSON.parse(content)) {
const group = PermissionGroup.fromJSON(groupRaw); resolvedAssignmentsUpdated(assignments: messages.AssignmentRecord[]) {
const instanceGroups = this.permissionGroups.get(group.instanceId); this.controller.subscriptions.broadcast(
if (instanceGroups) { new messages.ResolvedAssignmentUpdatedEvent(assignments),
instanceGroups.groups.set(group.name, group); assignments.map(assignment => assignment.name),
} else {
this.permissionGroups.set(group.instanceId,
new InstancePermissionGroups(group.instanceId, new Map([[group.name, group]]))
); );
} }
};
} catch (err: any) { async handleResolvedAssignmentSubscription(request: lib.SubscriptionRequest) {
if (err.code === "ENOENT") { // Check for any missing assignments to be computed on demand
this.logger.verbose("Creating new permission group database"); const filters = Array.isArray(request.filters) ? request.filters : [request.filters!];
return; if (request.filters && filters.length) {
} const missing = filters.filter(name => !this.resolvedAssignments.has(name));
throw err; if (missing.length) {
this.resolvedAssignments.setMany(await this.computeResolvedAssignments(missing));
} }
} }
async savePermissionGroups() { // Filter the assignments
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath); const assignments = (filters.length
this.logger.verbose(`Writing ${file}`); ? filters.map(name => this.resolvedAssignments.get(name)).filter(Boolean)
await lib.safeOutputFile(file, JSON.stringify( : [...this.resolvedAssignments.values()]
[...this.permissionGroups.values()].flatMap(instanceGroups => [...instanceGroups.groups.values()]) ).filter(a => a.updatedAtMs > request.lastRequestTimeMs);
));
return assignments.length ? new messages.ResolvedAssignmentUpdatedEvent(assignments) : null;
} }
async loadUserGroups() { /*
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return; Groups
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath); */
this.logger.verbose(`Loading ${file}`);
try {
const content = await fs.readFile(file, { encoding: "utf8" });
this.userToGroup = new Map(JSON.parse(content));
} catch (err: any) { async handleGroupListRequest() {
if (err.code === "ENOENT") { return [...this.groups.values()];
this.logger.verbose("Creating new user group database");
return;
}
throw err;
}
} }
async saveUserGroups() { async handleGroupCreateRequest(request: messages.GroupCreateRequest) {
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return; if ([...this.groups.values()].some(g => g.name === request.name)) {
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath); throw new lib.RequestError(`Group with name '${request.name}' already exists`);
this.logger.verbose(`Writing ${file}`);
await lib.safeOutputFile(file, JSON.stringify([...this.permissionGroups.entries()]));
} }
async loadData() { let id = Math.random() * 2**31 | 0;
await Promise.all([ while (this.groups.has(id)) {
this.loadPermissionGroups(), id = Math.random() * 2**31 | 0;
this.loadUserGroups(),
])
} }
async onSaveData() { const group = new messages.GroupRecord(id, request.name, request.permissions);
await Promise.all([ this.groups.set(group);
this.savePermissionGroups(),
this.saveUserGroups(),
])
}
addPermisisonGroup(instanceId: PermissionInstanceId, name: string, permissions = new Set<string>(), silent = false) {
const instanceGroups = this.permissionGroups.get(instanceId);
if (!instanceGroups) {
throw new Error("Instance ID does not exist");
}
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; return group;
} }
removePermissionGroup(instanceId: PermissionInstanceId, name: string, silent = false) { async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) {
const instanceGroups = this.permissionGroups.get(instanceId); const group = request.group;
if (!instanceGroups) { if (group.id === undefined || !this.groups.has(group.id)) {
throw new Error("Instance ID does not exist"); throw new lib.RequestError(`Group with ID ${group.id} does not exist`);
} }
const group = instanceGroups.groups.get(name)
this.groups.set(group);
}
async handleGroupDeleteRequest(request: messages.GroupDeleteRequest) {
const { groupId } = request;
const group = this.groups.getMutable(groupId);
if (!group) { if (!group) {
return null; throw new lib.RequestError(`Group with ID ${groupId} does not exist`);
} }
for (const nextGroup of instanceGroups.groups.values()) {
if (nextGroup.order > group.order) { this.groups.delete(group);
nextGroup.order -= 1;
} }
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`);
} }
instanceGroups.groups.delete(group.id);
group.updatedAtMs = Date.now();
group.isDeleted = true;
if (!silent) {
this.controller.subscriptions.broadcast(new PermissionGroupUpdate([group]));
}
return group; return group;
} }
async handlePermissionGroupEditEvent(event: PermissionGroupEditEvent) { /*
// TODO Groups
*/
async handleAssignmentListRequest() {
return [...this.manualAssignments.values()];
} }
async handlePermissionStringsUpdate(event: PermissionStringsUpdate) { async handleAssignmentCreateRequest(request: messages.AssignmentCreateRequest) {
for (const update of event.updates) { const { name, groupId } = request;
const global = this.permissionStrings.get("Global")! if (this.manualAssignments.has(name)) {
this.permissionStrings.set(update.instanceId as number, update) throw new lib.RequestError(`Assignment for '${name}' already exists`);
global.updatedAtMs = Math.max(global.updatedAtMs, update.updatedAtMs)
for (const permission of update.permissions) {
global.permissions.add(permission)
} }
// TODO maybe check if changes have happened rather than always pushing updates
this.controller.subscriptions.broadcast(new PermissionStringsUpdate([global, update])) 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;
} }
} }
async handlePermissionGroupUpdate(event: PermissionGroupUpdate) { if (!matches) continue;
const updates = [];
for (const group of event.updates) { if (!best || mapping.priority > best.priority) {
const groups = this.permissionGroups.get(group.instanceId); best = mapping;
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) { if (best) {
const updates = [ ...this.permissionStrings.values() ] return new messages.AssignmentRecord(playerName, best.groupId);
.filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
)
return updates.length ? new PermissionStringsUpdate(updates) : null;
} }
async handlePermissionGroupSubscription(request: lib.SubscriptionRequest, src: lib.Address) { // 3) Default (deleted assignment, assigns to 'Default' in game)
const updates = [ ...this.permissionGroups.values() ] return new messages.AssignmentRecord(playerName, 0, 0, true);
.flatMap(instanceGroups => [...instanceGroups.groups.values()])
.filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
)
if (src.type === lib.Address.instance) {
const instanceUpdates = updates.filter(group => group.instanceId === src.id || group.instanceId === "Global");
this.logger.info(JSON.stringify(updates))
this.logger.info(JSON.stringify(instanceUpdates))
return instanceUpdates.length ? new PermissionGroupUpdate(instanceUpdates) : null;
} }
return updates.length ? new PermissionGroupUpdate(updates) : null;
async computeResolvedAssignments(playerNames: string[]): Promise<messages.AssignmentRecord[]> {
return Promise.all(playerNames.map(name => this.computeResolvedAssignment(name)));
} }
} }
+163 -64
View File
@@ -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",
]
}; };
+143 -101
View File
@@ -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() {
// Send the most recent version of the permission string
const permissionsString = await this.sendRcon(rconBase + "rcon.print(Groups.get_actions_json())");
this.permissions = new Set(JSON.parse(permissionsString));
this.instance.sendTo("controller", new PermissionStringsUpdate([
new PermissionStrings(this.instance.id, this.permissions, Date.now())
]));
// Subscribe to get updates for permission groups
this.permissionGroups.subscribe(this.onPermissionGroupsUpdate.bind(this));
this.permissionGroupUpdates.subscribe(this.onPermissionGroupUpdate.bind(this));
}
async onControllerConnectionEvent(event: any) {
this.permissionGroups.handleConnectionEvent(event);
} }
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
if (field === "exp_groups.sync_permission_groups") { switch(field) {
this.syncId = curr ? "Global" : this.instance.id; case "exp_groups.sync_mode":
const [snapshot, synced] = this.permissionGroups.getSnapshot(); await this.luaSetEmitEvents(curr == "bidirectional")
if (synced && this.instance.status !== "running") await this.syncPermissionGroups(snapshot.values()); break;
} }
} }
async onPermissionGroupsUpdate(event: PermissionGroupUpdate | null, synced: boolean) { async onStart() {
if (!synced || this.instance.status !== "running" || !event?.updates.length) return; // We use Date.now() because we need to manually initialise the groups on the lua side
await this.syncPermissionGroups(event.updates); 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 syncPermissionGroups(groups: Iterable<PermissionGroup>) { async onPlayerEvent(event: lib.PlayerEvent) {
const updateCommands = [rconBase]; switch(event.type) {
for (const group of groups) { case "join":
if (group.instanceId === this.syncId && group.updatedAtMs > (this.permissionGroups.values.get(group.id)?.updatedAtMs ?? 0)) { this.onlinePlayers.add(event.name);
if (group.isDeleted) { await this.subscribePlayerAssignment(event.name);
updateCommands.push(`Groups.destroy_group('${group.name}')`); break;
} else if (group.permissions.size < this.permissions.size / 2) { case "leave":
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([false, [...this.permissions.values()]])}')`); this.onlinePlayers.delete(event.name);
await this.unsubscribePlayerAssignment(event.name);
break;
}
}
async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) {
for (const group of event.updates) {
await this.luaSendGroupUpdate(group);
}
}
async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) {
for (const assignment of event.updates) {
if (this.onlinePlayers.has(assignment.name)) {
await this.luaSendAssignmentUpdate(assignment);
}
}
}
async handleGroupUpdatedIPC(event: IpcGroupUpdated) {
const permissions = new messages.GroupPermissions(
event.permissions.is_blacklist,
event.permissions.permissions ?? [],
)
if (event.group_id === undefined) {
this.skipSendingPermissions.add(event.group_name);
await this.instance.sendTo("controller",
new messages.GroupCreateRequest(event.group_name, permissions),
);
} else { } else {
const inverted = [...this.permissions.values()].filter(permission => !group.permissions.has(permission)); this.skipSendingUpdate.add(event.group_id);
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([true, inverted])}')`); await this.instance.sendTo("controller", new messages.GroupUpdateRequest(
} new messages.GroupRecord(event.group_id, event.group_name, permissions),
} ));
}
await this.sendRcon(updateCommands.join(";"), true);
}
async onPermissionGroupUpdate(event: PermissionGroupEditEvent | null, synced: boolean) {
if (!synced || this.instance.status !== "running" || !event) return;
if (event.src.equals(lib.Address.fromShorthand({ instanceId: this.instance.id }))) return;
const getCmd = `Groups.get_or_create('${event.group}')`;
if (event.type === "add_permissions") {
await this.sendRcon(rconBase + getCmd + `:allow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
} else if (event.type === "remove_permissions") {
await this.sendRcon(rconBase + getCmd + `:disallow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
} else if (event.type === "assign_players") {
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;
this.instance.sendTo("controller", new PermissionGroupUpdate([ group ]));
} }
async unsubscribePlayerAssignment(playerName: string) {
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)
} }
} }
+664 -211
View File
@@ -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
*/
export class PermissionGroup {
constructor( constructor(
public instanceId: PermissionInstanceId, public isBlacklist: boolean,
public name: string, public permissions: 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(),
order: Type.Number(),
roleIds: Type.Array(Type.Number()),
permissions: Type.Array(Type.String()), 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(
json.instanceId,
json.name,
json.order,
new Set(json.roleIds),
new Set(json.permissions),
json.updatedAtMs,
json.isDeleted
);
}
toJSON(): Static<typeof PermissionGroup.jsonSchema> {
return { return {
instanceId: this.instanceId, is_blacklist: this.isBlacklist,
name: this.name, permissions: this.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 {
constructor(
public instanceId: PermissionInstanceId,
public groups: Map<PermissionGroup["name"], PermissionGroup> = new Map(),
) {
}
static jsonSchema = Type.Object({
instanceId: PermissionInstanceIdSchema,
permissionsGroups: Type.Array(PermissionGroup.jsonSchema),
});
static fromJSON(json: Static<typeof InstancePermissionGroups.jsonSchema>) {
return new InstancePermissionGroups(
json.instanceId,
new Map(json.permissionsGroups.map(group => [group.name, PermissionGroup.fromJSON(group)])),
);
}
toJSON() {
return {
instanceId: this.instanceId,
permissionsGroups: [...this.groups.values()],
}
}
getUserGroup(user: User) {
const groups = [...user.roleIds.values()].map(roleId =>
// 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 this.instanceId;
}
}
export class PermissionGroupUpdate {
declare ["constructor"]: typeof PermissionGroupUpdate;
static type = "event" as const;
static src = ["controller", "instance"] as const;
static dst = ["control", "instance", "controller"] as const;
static plugin = "exp_groups" as const;
static permission = "exp_groups.list.subscribe";
constructor(
public updates: PermissionGroup[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(PermissionGroup.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.updates.map(update => PermissionGroup.fromJSON(update))
);
}
}
export type PermissionGroupEditType = "assign_players" | "add_permissions" | "remove_permissions";
export class PermissionGroupEditEvent {
declare ["constructor"]: typeof PermissionGroupEditEvent;
static type = "event" as const;
static src = ["instance", "controller"] as const;
static dst = ["control", "instance", "controller"] as const;
static plugin = "exp_groups" as const;
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof PermissionGroupEditEvent.jsonSchema>;
if (data.type === "add_permissions" || data.type === "remove_permissions") {
user.checkPermission("exp_groups.modify_permissions")
} else if (data.type === "assign_players") {
user.checkPermission("exp_groups.assign_players")
} else {
throw new PermissionError("Permission denied");
}
}; };
} }
constructor(
public src: Address,
public type: PermissionGroupEditType,
public group: string,
public changes: String[],
) { }
static jsonSchema = Type.Object({
"src": Address.jsonSchema,
"type": StringEnum(["assign_players", "add_permissions", "remove_permissions"]),
"group": Type.String(),
"changes": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) { static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(Address.fromJSON(json.src), json.type, json.group, json.changes); return new this(
} json.is_blacklist,
} json.permissions,
export class PermissionStrings {
constructor(
public instanceId: PermissionInstanceId,
public permissions: Set<GamePermission>,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {
}
static jsonSchema = Type.Object({
instanceId: PermissionInstanceIdSchema,
permissions: Type.Array(Type.String()),
updatedAtMs: Type.Optional(Type.Number()),
isDeleted: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof PermissionStrings.jsonSchema>) {
return new PermissionStrings(
json.instanceId,
new Set(json.permissions),
json.updatedAtMs,
json.isDeleted
); );
} }
toJSON() {
return {
instanceId: this.instanceId,
permissions: [...this.permissions.values()],
updatedAtMs: this.updatedAtMs > 0 ? this.updatedAtMs : undefined,
isDeleted: this.isDeleted ? this.isDeleted : undefined,
}
}
get id() {
return this.instanceId
}
} }
export class PermissionStringsUpdate { export class GroupRecord {
declare ["constructor"]: typeof PermissionStringsUpdate;
static type = "event" as const;
static src = ["instance", "controller"] as const;
static dst = ["controller", "control"] as const;
static plugin = "exp_groups" as const;
static permission = "exp_groups.list.subscribe";
constructor( constructor(
public updates: PermissionStrings[], public id: number,
) { } public name: string,
public permissions: GroupPermissions,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
static jsonSchema = Type.Object({ static jsonSchema = Type.Object({
"updates": Type.Array(PermissionStrings.jsonSchema), id: Type.Integer(),
name: Type.String(),
permissions: GroupPermissions.jsonSchema,
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
}); });
toJSON() {
let json: Static<typeof GroupRecord.jsonSchema> = {
id: this.id,
name: this.name,
permissions: this.permissions.toJSON(),
};
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>) { static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this( return new this(
json.updates.map(update => PermissionStrings.fromJSON(update)) json.id,
json.name,
GroupPermissions.fromJSON(json.permissions),
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
); );
} }
} }
export class AssignmentRecord {
constructor(
public name: string,
public groupId: number,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
get id() {
return this.name;
}
static jsonSchema = Type.Object({
name: Type.String(),
group_id: Type.Integer(),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON(): Static<typeof AssignmentRecord.jsonSchema> {
let json: Static<typeof AssignmentRecord.jsonSchema> = {
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 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,
) {}
static jsonSchema = Type.Object({
id: Type.Integer(),
role_ids: Type.Array(Type.Integer()),
group_id: Type.Integer(),
priority: Type.Number(),
enabled: Type.Boolean(),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON(): Static<typeof RoleMappingRecord.jsonSchema> {
let json: Static<typeof RoleMappingRecord.jsonSchema> = {
id: this.id,
role_ids: [...this.roleIds],
group_id: this.groupId,
priority: this.priority,
enabled: this.enabled,
};
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.id,
new Set(json.role_ids),
json.group_id,
json.priority,
json.enabled,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
}
/*
Update events
*/
export class GroupUpdatedEvent {
declare ["constructor"]: typeof GroupUpdatedEvent;
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.group.subscribe" as const;
constructor(
public updates: GroupRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(GroupRecord.jsonSchema),
});
toJSON() {
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 ManualAssignmentUpdatedEvent {
declare ["constructor"]: typeof ManualAssignmentUpdatedEvent;
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 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
View File
@@ -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
+11
View File
@@ -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")
+2 -2
View File
@@ -4,9 +4,9 @@
"control.lua" "control.lua"
], ],
"require": [ "require": [
"globals.lua"
], ],
"dependencies": { "dependencies": {
"clusterio": "*", "clusterio": "*"
"exp_util": "*"
} }
} }
+2 -305
View File
@@ -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
View File
@@ -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"
] ]
} }
+2 -2
View File
@@ -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>
}
+45
View File
@@ -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>;
}
+287
View File
@@ -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>;
}
+49
View File
@@ -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} />
</>;
}
-129
View File
@@ -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
View File
@@ -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());
} }
} }
+2
View File
@@ -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 },
}, },
}), }),
], ],
+2 -2
View File
@@ -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:",
+2 -2
View File
@@ -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:",
+2 -3
View File
@@ -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() {
}
} }
+3 -3
View File
@@ -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:",
-68
View File
@@ -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,
};
}
}
+3 -3
View File
@@ -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:"
+2 -2
View File
@@ -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:",
-12
View File
@@ -1,12 +0,0 @@
{
"name": "root",
"private": true,
"files": [],
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch"
},
"devDependencies": {
"typescript": "catalog:"
}
}
-3782
View File
File diff suppressed because it is too large Load Diff
-16
View File
@@ -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