From df1edec62343bb2da9313e96151d42c1adb79fd9 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:08:51 +0100 Subject: [PATCH] Add role subscriptions and data model --- packages/controller/src/Controller.ts | 31 +++- packages/lib/src/data/Role.ts | 10 +- packages/lib/src/data/index.ts | 1 + packages/lib/src/data/messages_role.ts | 134 ++++++++++++++++++ packages/lib/src/data/messages_user.ts | 114 +-------------- packages/lib/src/link/messages.ts | 15 +- packages/lib/src/permissions.ts | 6 + packages/web_ui/src/components/InputRole.tsx | 15 +- .../web_ui/src/components/RoleViewPage.tsx | 29 +--- packages/web_ui/src/components/RolesPage.tsx | 13 +- .../web_ui/src/components/UserViewPage.tsx | 12 +- packages/web_ui/src/components/UsersPage.tsx | 13 +- packages/web_ui/src/model/roles.ts | 23 +++ packages/web_ui/src/util/websocket.ts | 1 + test/controller/Controller.js | 30 +++- 15 files changed, 244 insertions(+), 203 deletions(-) create mode 100644 packages/lib/src/data/messages_role.ts create mode 100644 packages/web_ui/src/model/roles.ts diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index b22856fd..024b0c51 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -141,6 +141,7 @@ export default class Controller { const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "roles.json"), lib.Role.fromJSON.bind(lib.Role), + this.migrateRoles, ).bootstrap()); const userManager = new UserManager(config, roles); @@ -227,6 +228,7 @@ export default class Controller { this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this)); this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this)); this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this)); + this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this)); // Handle updates for datastores this.systems.on("update", this.systemsUpdated.bind(this)); @@ -672,6 +674,20 @@ export default class Controller { return instance; } + static migrateRoles(rawJson: unknown[]): Static[] { + const serialized = rawJson as Static[]; + return serialized.map(json => { + json.permissions = json.permissions.map(permission => { + // migrate: core.instance.save.list.subscribe was renamed in alpha 17 + if (permission === "core.instance.save.list.subscribe") { + return "core.instance.save.subscribe"; + } + return permission; + }); + return json; + }); + } + static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise { let manifest = {}; try { @@ -1075,12 +1091,10 @@ export default class Controller { } } - /** - * Notify connected control clients with the given role that the - * permissions may have changed. - * @param roles - Roles updated. - */ rolesUpdated(roles: lib.Role[]) { + this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles)); + // lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment + // Notify connected control clients with the given role that the permissions may have changed. for (const role of roles) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user.roles.has(role)) { @@ -1098,6 +1112,13 @@ export default class Controller { } } + async handleRoleSubscription(request: lib.SubscriptionRequest) { + const roles = [...this.roles.values()].filter( + role => role.updatedAtMs > request.lastRequestTimeMs, + ); + return roles.length ? new lib.RoleUpdatesEvent(roles) : null; + } + async loadPlugins() { for (let pluginInfo of this.pluginInfos) { try { diff --git a/packages/lib/src/data/Role.ts b/packages/lib/src/data/Role.ts index b462fe42..3ce7c704 100644 --- a/packages/lib/src/data/Role.ts +++ b/packages/lib/src/data/Role.ts @@ -23,20 +23,12 @@ export default class Role { is_deleted: Type.Optional(Type.Boolean()), }); - static remapPermission(permission: string) { - // migrate: core.instance.save.list.subscribe was renamed in alpha 17 - if (permission === "core.instance.save.list.subscribe") { - return "core.instance.save.subscribe"; - } - return permission; - } - static fromJSON(json: Static) { return new this( json.id, json.name, json.description, - new Set(json.permissions.map(this.remapPermission)), + new Set(json.permissions), json.updated_at_ms, json.is_deleted, ); diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index d3f781cf..cacc1211 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -21,5 +21,6 @@ export * from "./messages_host"; export * from "./messages_instance"; export * from "./messages_mod"; export * from "./messages_user"; +export * from "./messages_role"; export * from "./messages_plugin"; export * from "./version"; diff --git a/packages/lib/src/data/messages_role.ts b/packages/lib/src/data/messages_role.ts new file mode 100644 index 00000000..423c1d65 --- /dev/null +++ b/packages/lib/src/data/messages_role.ts @@ -0,0 +1,134 @@ +import { Type, Static } from "@sinclair/typebox"; +import { JsonNumber, jsonArray } from "./composites"; +import Permission from "./Permission"; +import Role from "./Role"; + +export class PermissionListRequest { + declare ["constructor"]: typeof PermissionListRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.permission.list" as const; + static Response = jsonArray(Permission); +} + +export class RoleListRequest { + declare ["constructor"]: typeof RoleListRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.list" as const; + static Response = jsonArray(Role); +} + +export class RoleUpdatesEvent { + declare ["constructor"]: typeof RoleUpdatesEvent; + static type = "event" as const; + static src = "controller" as const; + static dst = "control" as const; + static permission = "core.role.subscribe" as const; + + constructor( + public updates: Role[], + ) { } + + static jsonSchema = Type.Object({ + "updates": Type.Array(Role.jsonSchema), + }); + + static fromJSON(json: Static) { + return new this(json.updates.map(update => Role.fromJSON(update))); + } +} + +export class RoleCreateRequest { + declare ["constructor"]: typeof RoleCreateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.create" as const; + + constructor( + public name: string, + public description: string, + public permissions: string[], + ) { } + + static jsonSchema = Type.Object({ + name: Type.String(), + description: Type.String(), + permissions: Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.name, json.description, json.permissions); + } + + static Response = JsonNumber; +} + +export class RoleUpdateRequest { + declare ["constructor"]: typeof RoleUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.update" as const; + + constructor( + public id: number, + public name: string, + public description: string, + public permissions: string[], + ) { } + + static jsonSchema = Type.Object({ + id: Type.Integer(), + name: Type.String(), + description: Type.String(), + permissions: Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.id, json.name, json.description, json.permissions); + } +} + +export class RoleGrantDefaultPermissionsRequest { + declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.update" as const; + + constructor( + public id: number, + ) { } + + static jsonSchema = Type.Object({ + id: Type.Integer(), + }); + + static fromJSON(json: Static) { + return new this(json.id); + } +} + +export class RoleDeleteRequest { + declare ["constructor"]: typeof RoleDeleteRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.delete" as const; + + constructor( + public id: number, + ) { } + + static jsonSchema = Type.Object({ + id: Type.Integer(), + }); + + static fromJSON(json: Static) { + return new this(json.id); + } +} diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index c827b69b..cb35eb6c 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,120 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; -import Permission from "./Permission"; -import Role from "./Role"; import User, { IControllerUser } from "./User"; -import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites"; +import { StringEnum, jsonArray, plainJson } from "./composites"; import { MessageRequest } from "./messages_core"; -export class PermissionListRequest { - declare ["constructor"]: typeof PermissionListRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.permission.list" as const; - static Response = jsonArray(Permission); -} - -export class RoleListRequest { - declare ["constructor"]: typeof RoleListRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.list" as const; - static Response = jsonArray(Role); -} - -export class RoleCreateRequest { - declare ["constructor"]: typeof RoleCreateRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.create" as const; - - constructor( - public name: string, - public description: string, - public permissions: string[], - ) { } - - static jsonSchema = Type.Object({ - name: Type.String(), - description: Type.String(), - permissions: Type.Array(Type.String()), - }); - - static fromJSON(json: Static) { - return new this(json.name, json.description, json.permissions); - } - - static Response = JsonNumber; -} - -export class RoleUpdateRequest { - declare ["constructor"]: typeof RoleUpdateRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.update" as const; - - constructor( - public id: number, - public name: string, - public description: string, - public permissions: string[], - ) { } - - static jsonSchema = Type.Object({ - id: Type.Integer(), - name: Type.String(), - description: Type.String(), - permissions: Type.Array(Type.String()), - }); - - static fromJSON(json: Static) { - return new this(json.id, json.name, json.description, json.permissions); - } -} - -export class RoleGrantDefaultPermissionsRequest { - declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.update" as const; - - constructor( - public id: number, - ) { } - - static jsonSchema = Type.Object({ - id: Type.Integer(), - }); - - static fromJSON(json: Static) { - return new this(json.id); - } -} - -export class RoleDeleteRequest { - declare ["constructor"]: typeof RoleDeleteRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.delete" as const; - - constructor( - public id: number, - ) { } - - static jsonSchema = Type.Object({ - id: Type.Integer(), - }); - - static fromJSON(json: Static) { - return new this(json.id); - } -} - export class UserGetRequest { declare ["constructor"]: typeof UserGetRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 5d4977c1..3fd30de6 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -4,6 +4,7 @@ import * as host from "../data/messages_host"; import * as instance from "../data/messages_instance"; import * as mod from "../data/messages_mod"; import * as user from "../data/messages_user"; +import * as role from "../data/messages_role"; import * as plugin from "../data/messages_plugin"; import * as subscriptions from "../subscriptions"; import type { RequestClass, EventClass } from "./link"; @@ -107,12 +108,14 @@ export const dataClasses: (RequestClass | EventClass) mod.ModUpdatesEvent, mod.ModPortalDownloadRequest, - user.PermissionListRequest, - user.RoleListRequest, - user.RoleCreateRequest, - user.RoleUpdateRequest, - user.RoleGrantDefaultPermissionsRequest, - user.RoleDeleteRequest, + role.PermissionListRequest, + role.RoleListRequest, + role.RoleUpdatesEvent, + role.RoleCreateRequest, + role.RoleUpdateRequest, + role.RoleGrantDefaultPermissionsRequest, + role.RoleDeleteRequest, + user.UserGetRequest, user.UserListRequest, user.UserCreateRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 5c295823..23ac4b31 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -432,6 +432,12 @@ definePermission({ description: "Get the full list of roles and their permissions in the cluster.", grantByDefault: true, }); +definePermission({ + name: "core.role.subscribe", + title: "Subscribe to role updates", + description: "Subscribe to be notified on updates on the details of roles.", + grantByDefault: true, +}); definePermission({ name: "core.role.create", title: "Create role", diff --git a/packages/web_ui/src/components/InputRole.tsx b/packages/web_ui/src/components/InputRole.tsx index 49a9a78f..e8039cda 100644 --- a/packages/web_ui/src/components/InputRole.tsx +++ b/packages/web_ui/src/components/InputRole.tsx @@ -1,21 +1,10 @@ -import React, { useContext, useEffect, useState } from "react"; import { Select } from "antd"; -import * as lib from "@clusterio/lib"; - -import ControlContext from "./ControlContext"; -import { notifyErrorHandler } from "../util/notify"; import { InputComponentProps } from "../BaseWebPlugin"; +import { useRoles } from "../model/roles"; export default function InputRole(props: InputComponentProps) { - let control = useContext(ControlContext); - let [roles, setRoles] = useState([]); - - useEffect(() => { - control.send(new lib.RoleListRequest()).then(newRoles => { - setRoles(newRoles); - }).catch(notifyErrorHandler("Error fetching role list")); - }, []); + const [roles] = useRoles(); return