diff --git a/db/knex_migrations/2025-07-15-0000-add-user-type.js b/db/knex_migrations/2025-07-15-0000-add-user-type.js new file mode 100644 index 000000000..644b5dfc6 --- /dev/null +++ b/db/knex_migrations/2025-07-15-0000-add-user-type.js @@ -0,0 +1,15 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('user', function (table) { + table.string('user_type').notNullable().defaultTo('admin'); + }); + + // If you want to set a default user type for existing users, you can do it here. + // For example, set all existing users to 'admin' + // await knex('user').update({ user_type: 'admin' }); +}; + +exports.down = async function (knex) { + await knex.schema.alterTable('user', function (table) { + table.dropColumn('user_type'); + }); +}; diff --git a/server/model/user.js b/server/model/user.js index 33277d485..396d30ab7 100644 --- a/server/model/user.js +++ b/server/model/user.js @@ -44,10 +44,40 @@ class User extends BeanModel { static createJWT(user, jwtSecret) { return jwt.sign({ username: user.username, + // Include user_type in the JWT payload if needed for frontend/client-side checks, + // but remember that JWTs can be decoded by the client. + // For sensitive authorization, always re-check user_type on the server-side. + user_type: user.user_type, h: shake256(user.password, SHAKE256_LENGTH), }, jwtSecret); } + /** + * Returns the user type. + * Note: This assumes the 'user_type' field is populated in the bean. + * If you load a user bean partially, ensure 'user_type' is selected. + * @returns {string | null} The user type string or null if not set/loaded. + */ + getUserType() { + return this.user_type || null; + } + + /** + * Sets the user type. + * Remember to save the bean after setting the type for changes to persist. + * @param {string} newUserType The new type for the user. + * @returns {Promise} + */ + async setUserType(newUserType) { + // Basic validation can be added here if desired, e.g., + // const allowedTypes = ['admin', 'editor', 'viewer']; + // if (!allowedTypes.includes(newUserType)) { + // throw new Error(`Invalid user type: ${newUserType}`); + // } + this.user_type = newUserType; + // Note: This only updates the property on the model instance. + // You need to call R.store(userBean) to persist the change to the database. + } } module.exports = User; diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index b996efe7b..bff7d257d 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -1,7 +1,9 @@ const { log } = require("../../src/util"); const { Settings } = require("../settings"); const { sendInfo } = require("../client"); -const { checkLogin } = require("../util-server"); +const { checkLogin, isAdmin } = require("../util-server"); // Added isAdmin +const { R } = require("redbean-node"); // Added R for database operations +const User = require("../model/user"); // Added User model const GameResolver = require("gamedig/lib/GameResolver"); const { testChrome } = require("../monitor-types/real-browser-monitor-type"); const fsAsync = require("fs").promises; @@ -136,4 +138,77 @@ module.exports.generalSocketHandler = (socket, server) => { log.warn("disconnectAllSocketClients", e.message); } }); + + // User Management Socket Handlers + // Only admins should be able to manage users. + + socket.on("getUsers", async (callback) => { + try { + checkLogin(socket); + await isAdmin(socket); // Ensure the user is an admin + + const userList = await R.findAll("user", "ORDER BY username"); + // Avoid sending password hashes to the client + const sanitizedUserList = userList.map(user => { + const { password, ...sanitizedUser } = user.export(); + return sanitizedUser; + }); + + callback({ + ok: true, + users: sanitizedUserList, + }); + } catch (e) { + log.error("getUsers", e.message); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("updateUserType", async (userID, newUserType, callback) => { + try { + checkLogin(socket); + await isAdmin(socket); // Ensure the user is an admin + + if (!userID || !newUserType) { + throw new Error("User ID and new user type are required."); + } + + // Prevent admin from changing their own type if they are the only admin? + // Or prevent changing the type of the main admin user? (e.g., user with ID 1) + // For now, let's assume such checks are handled by higher-level logic or are not required. + + const user = await R.findOne("user", "id = ?", [userID]); + if (!user) { + throw new Error("User not found."); + } + + // Potentially validate newUserType against a list of allowed types + const allowedTypes = ["admin", "editor", "viewer"]; // Example types + if (!allowedTypes.includes(newUserType)) { + throw new Error(`Invalid user type: ${newUserType}. Allowed types are: ${allowedTypes.join(", ")}`); + } + + user.user_type = newUserType; + await R.store(user); + + // Optionally, emit an event to other admins that a user type has changed + // server.sendToAdmins("userTypeChanged", { userID, newUserType }); + + callback({ + ok: true, + msg: `User type for user ID ${userID} updated to ${newUserType}.`, + }); + + } catch (e) { + log.error("updateUserType", e.message); + callback({ + ok: false, + msg: e.message, + }); + } + }); + }; diff --git a/server/util-server.js b/server/util-server.js index 4da4be91b..b02848d21 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -798,6 +798,29 @@ exports.checkLogin = (socket) => { } }; +/** + * Check if a user is an admin. + * Throws an error if the user is not logged in or not an admin. + * @param {Socket} socket Socket instance + * @returns {Promise} + * @throws Error if not logged in or not an admin + */ +exports.isAdmin = async (socket) => { + exports.checkLogin(socket); // Ensure user is logged in first + + const user = await R.findOne("user", "id = ? AND active = 1", [socket.userID]); + + if (!user) { + // This case should ideally be caught by checkLogin or represent an anomaly + throw new Error("User not found or not active."); + } + + if (user.user_type !== "admin") { + throw new Error("Administrator privileges required."); + } + // If we reach here, the user is an admin. +}; + /** * For logged-in users, double-check the password * @param {Socket} socket Socket.io instance diff --git a/src/components/settings/UserManagement.vue b/src/components/settings/UserManagement.vue new file mode 100644 index 000000000..ec4c450b7 --- /dev/null +++ b/src/components/settings/UserManagement.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 96bb1fee1..f9612951b 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -110,6 +110,9 @@ export default { security: { title: this.$t("Security"), }, + "user-management": { // New Menu Item + title: this.$t("User Management"), + }, "api-keys": { title: this.$t("API Keys") }, diff --git a/src/router.js b/src/router.js index bda5078e1..8940d10d0 100644 --- a/src/router.js +++ b/src/router.js @@ -29,6 +29,7 @@ import ReverseProxy from "./components/settings/ReverseProxy.vue"; import Tags from "./components/settings/Tags.vue"; import MonitorHistory from "./components/settings/MonitorHistory.vue"; const Security = () => import("./components/settings/Security.vue"); +const UserManagement = () => import("./components/settings/UserManagement.vue"); // Added UserManagement import Proxies from "./components/settings/Proxies.vue"; import About from "./components/settings/About.vue"; import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue"; @@ -124,6 +125,10 @@ const routes = [ path: "security", component: Security, }, + { + path: "user-management", // New Route + component: UserManagement, + }, { path: "api-keys", component: APIKeys,