new Plugin permissions framework (#7667)

* Hot reload plugins * Add option for plugin permissions, hot plugin reloads without rebooting server. * CRLF to LF line endings * postgre and sqlite db table creation fixes so that tables are checked for existence at each MC start

ryanblenis committed Mar 15, 2026 at 07:21 UTC 3e0f025b01ab3ba9c5b16a0cdcccc6814e97dc01
5 files changed +1881 -27
db.js
+31 -9
@@ -757,7 +757,8 @@ module.exports.CreateDB = function (parent, func) {
757 'CREATE TABLE IF NOT EXISTS serverstats (time DATETIME, expire DATETIME, doc JSON, PRIMARY KEY(time), CHECK (json_valid(doc)))',
758 'CREATE TABLE IF NOT EXISTS power (id INT NOT NULL AUTO_INCREMENT, time DATETIME, nodeid CHAR(255), doc JSON, PRIMARY KEY(id), CHECK (json_valid(doc)))',
759 'CREATE TABLE IF NOT EXISTS smbios (id CHAR(255), time DATETIME, expire DATETIME, doc JSON, PRIMARY KEY(id), CHECK (json_valid(doc)))',
760 - 'CREATE TABLE IF NOT EXISTS plugin (id INT NOT NULL AUTO_INCREMENT, doc JSON, PRIMARY KEY(id), CHECK (json_valid(doc)))'
760 + 'CREATE TABLE IF NOT EXISTS plugin (id INT NOT NULL AUTO_INCREMENT, doc JSON, PRIMARY KEY(id), CHECK (json_valid(doc)))',
761 + 'CREATE TABLE IF NOT EXISTS pluginpermissions (id VARCHAR(255) PRIMARY KEY, doc JSON)'
762 ], function (err) {
763 parent.debug('db', 'Checking indexes...');
764 sqlDbExec('CREATE INDEX ndxtypedomainextra ON main (type, domain, extra)', null, function (err, response) { });
@@ -813,6 +814,7 @@ module.exports.CreateDB = function (parent, func) {
814 CREATE TABLE power (id INTEGER PRIMARY KEY, time TIMESTAMP, nodeid CHAR(255), doc JSON);
815 CREATE TABLE smbios (id CHAR(255) PRIMARY KEY, time TIMESTAMP, expire TIMESTAMP, doc JSON);
816 CREATE TABLE plugin (id INTEGER PRIMARY KEY, doc JSON);
817 + CREATE TABLE pluginpermissions (id VARCHAR(255) PRIMARY KEY, doc JSON);
818 CREATE INDEX ndxtypedomainextra ON main (type, domain, extra);
819 CREATE INDEX ndxextra ON main (extra);
820 CREATE INDEX ndxextraex ON main (extraex);
@@ -838,8 +840,14 @@ module.exports.CreateDB = function (parent, func) {
840
841 //for existing db's
842 sqliteSetOptions();
841 - //setupFunctions could be put in the sqliteSetupOptions, but left after it for clarity
842 - setupFunctions(func);
843 + // Create any missing tables (e.g., pluginpermissions added in updates)
844 + obj.file.exec(`
845 + CREATE TABLE IF NOT EXISTS pluginpermissions (id VARCHAR(255) PRIMARY KEY, doc JSON)
846 + `, function (err) {
847 + if (err) { console.log("SQLite Error creating pluginpermissions table: " + err); }
848 + //setupFunctions could be put in the sqliteSetupOptions, but left after it for clarity
849 + setupFunctions(func);
850 + });
851 });
852 } else if (parent.args.acebase) {
853 // AceBase database setup
@@ -940,7 +948,9 @@ module.exports.CreateDB = function (parent, func) {
948 Datastore.connect();
949 Datastore.query('SELECT doc FROM main WHERE id = $1', ['DatabaseIdentifier'], function (err, res) {
950 if (err == null) {
943 - (res.rowCount == 0) ? postgreSqlCreateTables(func) : setupFunctions(func);
951 + // Always call postgreSqlCreateTables since it uses CREATE TABLE IF NOT EXISTS
952 + // This ensures new tables (like pluginpermissions) get created on upgrades
953 + postgreSqlCreateTables(func);
954 } else if (err.code == '42P01') { //42P01 = undefined table
955 postgreSqlCreateTables(func);
956 } else {
@@ -960,7 +970,9 @@ module.exports.CreateDB = function (parent, func) {
970 Datastore.connect();
971 Datastore.query('SELECT doc FROM main WHERE id = $1', ['DatabaseIdentifier'], function (err, res) {
972 if (err == null) {
963 - (res.rowCount ==0) ? postgreSqlCreateTables(func) : setupFunctions(func)
973 + // Always call postgreSqlCreateTables since it uses CREATE TABLE IF NOT EXISTS
974 + // This ensures new tables (like pluginpermissions) get created on upgrades
975 + postgreSqlCreateTables(func);
976 } else
977 if (err.code == '42P01') { //42P01 = undefined table, https://www.postgresql.org/docs/current/errcodes-appendix.html
978 postgreSqlCreateTables(func);
@@ -1162,8 +1174,8 @@ module.exports.CreateDB = function (parent, func) {
1174 }
1175 });
1176
1165 - // Setup plugin info collection
1166 - if (obj.pluginsActive) { obj.pluginsfile = db.collection('plugins'); }
1177 + // Setup plugin info collection
1178 + if (obj.pluginsActive) { obj.pluginsfile = db.collection('plugins'); obj.pluginpermissionsfile = db.collection('pluginpermissions'); }
1179
1180 setupFunctions(func); // Completed setup of MongoDB
1181 });
@@ -1397,7 +1409,8 @@ module.exports.CreateDB = function (parent, func) {
1409 'CREATE TABLE IF NOT EXISTS serverstats (time TIMESTAMP PRIMARY KEY, expire TIMESTAMP, doc JSON)',
1410 'CREATE TABLE IF NOT EXISTS power (id SERIAL PRIMARY KEY, time TIMESTAMP, nodeid CHAR(255), doc JSON)',
1411 'CREATE TABLE IF NOT EXISTS smbios (id CHAR(255) PRIMARY KEY, time TIMESTAMP, expire TIMESTAMP, doc JSON)',
1400 - 'CREATE TABLE IF NOT EXISTS plugin (id SERIAL PRIMARY KEY, doc JSON)'
1412 + 'CREATE TABLE IF NOT EXISTS plugin (id SERIAL PRIMARY KEY, doc JSON)',
1413 + 'CREATE TABLE IF NOT EXISTS pluginpermissions (id VARCHAR(255) PRIMARY KEY, doc JSON)'
1414 ], function (results) {
1415 parent.debug('db', 'Creating indexes...');
1416 sqlDbExec('CREATE INDEX ndxtypedomainextra ON main (type, domain, extra)', null, function (err, response) { });
@@ -1884,6 +1897,8 @@ module.exports.CreateDB = function (parent, func) {
1897 obj.deletePlugin = function (id, func) { sqlDbQuery('DELETE FROM plugin WHERE id = $1', [id], func); }; // Delete plugin
1898 obj.setPluginStatus = function (id, status, func) { sqlDbQuery('UPDATE plugin SET doc=JSON_SET(doc,"$.status",$1) WHERE id=$2', [status,id], func); };
1899 obj.updatePlugin = function (id, args, func) { delete args._id; sqlDbQuery('UPDATE plugin SET doc=json_patch(doc,$1) WHERE id=$2', [JSON.stringify(args),id], func); };
1900 + obj.getPluginPermissions = function (pluginName, func) { sqlDbQuery('SELECT doc FROM pluginpermissions WHERE id = $1', ['pluginpermission//' + pluginName], function(err, docs) { if (docs && docs.length > 0) { func(null, [docs[0].doc]); } else { func(null, []); } }); };
1901 + obj.setPluginPermissions = function (pluginName, data, func) { delete data._id; sqlDbQuery('INSERT INTO pluginpermissions VALUES ($1, $2) ON DUPLICATE KEY UPDATE doc = $2', ['pluginpermission//' + pluginName, JSON.stringify(data)], func); };
1902 }
1903 } else if (obj.databaseType == DB_ACEBASE) {
1904 // Database actions on the main collection. AceBase: https://github.com/appy-one/acebase
@@ -2176,6 +2191,8 @@ module.exports.CreateDB = function (parent, func) {
2191 obj.deletePlugin = function (id, func) { obj.file.ref('plugin').child(encodeURIComponent(id)).remove().then(function () { if (func) { func(); } }); }; // Delete plugin
2192 obj.setPluginStatus = function (id, status, func) { obj.file.ref('plugin').child(encodeURIComponent(id)).update({ status: status }).then(function (ref) { if (func) { func(); } }) };
2193 obj.updatePlugin = function (id, args, func) { delete args._id; obj.file.ref('plugin').child(encodeURIComponent(id)).set(args).then(function (ref) { if (func) { func(); } }) };
2194 + obj.getPluginPermissions = function (pluginName, func) { obj.file.ref('pluginpermissions').child('pluginpermission//' + pluginName).get(function(snapshot) { if (snapshot.exists()) { func(null, [snapshot.val()]); } else { func(null, []); } }); };
2195 + obj.setPluginPermissions = function (pluginName, data, func) { delete data._id; obj.file.ref('pluginpermissions').child('pluginpermission//' + pluginName).set(data, func); };
2196 }
2197 } else if (obj.databaseType == DB_POSTGRESQL) {
2198 // Database actions on the main collection (Postgres)
@@ -2436,6 +2453,8 @@ module.exports.CreateDB = function (parent, func) {
2453 obj.deletePlugin = function (id, func) { sqlDbQuery('DELETE FROM plugin WHERE id = $1', [id], func); }; // Delete plugin
2454 obj.setPluginStatus = function (id, status, func) { sqlDbQuery("UPDATE plugin SET doc= jsonb_set(doc::jsonb,'{status}',$1) WHERE id=$2", [status,id], func); };
2455 obj.updatePlugin = function (id, args, func) { delete args._id; sqlDbQuery('UPDATE plugin SET doc= doc::jsonb || ($1) WHERE id=$2', [args,id], func); };
2456 + obj.getPluginPermissions = function (pluginName, func) { sqlDbQuery('SELECT doc FROM pluginpermissions WHERE id = $1', ['pluginpermission//' + pluginName], function(err, docs) { if (docs && docs.length > 0 && docs[0].doc) { func(null, [typeof docs[0].doc === 'string' ? JSON.parse(docs[0].doc) : docs[0].doc]); } else { func(null, []); } }); };
2457 + obj.setPluginPermissions = function (pluginName, data, func) { delete data._id; sqlDbQuery('INSERT INTO pluginpermissions VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET doc = $2', ['pluginpermission//' + pluginName, JSON.stringify(data)], func); };
2458 }
2459 } else if ((obj.databaseType == DB_MARIADB) || (obj.databaseType == DB_MYSQL)) {
2460 // Database actions on the main collection (MariaDB or MySQL)
@@ -3001,6 +3020,8 @@ module.exports.CreateDB = function (parent, func) {
3020 obj.deletePlugin = function (id, func) { id = require('mongodb').ObjectId(id); obj.pluginsfile.deleteOne({ _id: id }, func); }; // Delete plugin
3021 obj.setPluginStatus = function (id, status, func) { id = require('mongodb').ObjectId(id); obj.pluginsfile.updateOne({ _id: id }, { $set: { status: status } }, func); };
3022 obj.updatePlugin = function (id, args, func) { delete args._id; id = require('mongodb').ObjectId(id); obj.pluginsfile.updateOne({ _id: id }, { $set: args }, func); };
3023 + obj.getPluginPermissions = function (pluginName, func) { obj.pluginpermissionsfile.findOne({ _id: 'pluginpermission//' + pluginName }, function(err, doc) { func(err, doc ? [doc] : []); }); };
3024 + obj.setPluginPermissions = function (pluginName, data, func) { delete data._id; obj.pluginpermissionsfile.updateOne({ _id: 'pluginpermission//' + pluginName }, { $set: data }, { upsert: true }, func); };
3025 }
3026
3027 } else {
@@ -3217,8 +3238,9 @@ module.exports.CreateDB = function (parent, func) {
3238 obj.deletePlugin = function (id, func) { obj.pluginsfile.remove({ _id: id }, func); }; // Delete plugin
3239 obj.setPluginStatus = function (id, status, func) { obj.pluginsfile.update({ _id: id }, { $set: { status: status } }, func); };
3240 obj.updatePlugin = function (id, args, func) { delete args._id; obj.pluginsfile.update({ _id: id }, { $set: args }, func); };
3241 + obj.getPluginPermissions = function (pluginName, func) { obj.pluginsfile.findOne({ _id: 'pluginpermission//' + pluginName }, function(err, doc) { func(err, doc ? [doc] : []); }); };
3242 + obj.setPluginPermissions = function (pluginName, data, func) { delete data._id; obj.pluginsfile.update({ _id: 'pluginpermission//' + pluginName }, { $set: data }, { upsert: true }, func); };
3243 }
3221 -
3244 }
3245
3246 // Get all configuration files
meshuser.js
+90
@@ -4745,6 +4745,96 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
4745 });
4746 break;
4747 }
4748 + case 'reloadplugin': {
4749 + if ((user.siteadmin != SITERIGHT_ADMIN) || (parent.parent.pluginHandler == null)) break; // Must be full admin with plugins enabled
4750 + if (command.plugin == "ALL") {
4751 + // Reload all plugins
4752 + parent.parent.pluginHandler.reloadAllPlugins(function(result) {
4753 + try { ws.send(JSON.stringify({ action: 'pluginReloaded', result: result })); } catch (ex) { }
4754 + });
4755 + } else {
4756 + // Reload specific plugin
4757 + parent.parent.pluginHandler.reloadPlugin(command.plugin, function(result) {
4758 + try { ws.send(JSON.stringify({ action: 'pluginReloaded', result: result })); } catch (ex) { }
4759 + });
4760 + }
4761 + break;
4762 + }
4763 + case 'getpluginpermissions': {
4764 + if ((user.siteadmin != SITERIGHT_ADMIN) || (parent.parent.pluginHandler == null)) break; // Must be full admin
4765 + var perms = parent.parent.pluginHandler.getPluginPermissions(command.plugin);
4766 + try { ws.send(JSON.stringify({ action: 'pluginPermissions', plugin: command.plugin, permissions: perms })); } catch (ex) { }
4767 + break;
4768 + }
4769 + case 'setpluginpermissions': {
4770 + if ((user.siteadmin != SITERIGHT_ADMIN) || (parent.parent.pluginHandler == null)) break; // Must be full admin
4771 + parent.parent.pluginHandler.setPluginPermissions(command.plugin, command.data, function(err) {
4772 + try { ws.send(JSON.stringify({ action: 'pluginPermissionsSet', plugin: command.plugin, success: !err, error: err })); } catch (ex) { }
4773 + });
4774 + break;
4775 + }
4776 + case 'getpluginpermissionlist': {
4777 + // Return list of users, user groups, meshes, nodes for permission assignment UI
4778 + if ((user.siteadmin != SITERIGHT_ADMIN) || (parent.parent.pluginHandler == null)) break;
4779 +
4780 + var result = { users: [], userGroups: [], meshes: [], nodes: [] };
4781 +
4782 + // Get all users
4783 + parent.db.GetAllType('user', function(err, docs) {
4784 + if (docs) {
4785 + docs.forEach(function(u) {
4786 + if (u.name && u._id) {
4787 + result.users.push({ _id: u._id, name: u.name, email: u.email });
4788 + }
4789 + });
4790 + }
4791 +
4792 + // Get all user groups
4793 + parent.db.GetAllType('ugrp', function(err, ugrps) {
4794 + if (ugrps) {
4795 + ugrps.forEach(function(ug) {
4796 + if (ug.name && ug._id) {
4797 + result.userGroups.push({ _id: ug._id, name: ug.name });
4798 + }
4799 + });
4800 + }
4801 +
4802 + // Get all meshes (device groups)
4803 + parent.db.GetAllType('mesh', function(err, meshes) {
4804 + if (meshes) {
4805 + meshes.forEach(function(m) {
4806 + if (m.name && m._id && !m.deleted) {
4807 + result.meshes.push({ _id: m._id, name: m.name });
4808 + }
4809 + });
4810 + }
4811 +
4812 + // Get all nodes (devices)
4813 + parent.db.GetAllType('node', function(err, nodes) {
4814 + if (nodes) {
4815 + // Create a map of meshid to meshname for grouping
4816 + var meshMap = {};
4817 + if (meshes) {
4818 + meshes.forEach(function(m) {
4819 + meshMap[m._id] = m.name;
4820 + });
4821 + }
4822 +
4823 + nodes.forEach(function(n) {
4824 + if (n.name && n._id && !n.deleted) {
4825 + var meshname = meshMap[n.meshid] || 'Ungrouped';
4826 + result.nodes.push({ _id: n._id, name: n.name, meshid: n.meshid, meshname: meshname });
4827 + }
4828 + });
4829 + }
4830 +
4831 + try { ws.send(JSON.stringify({ action: 'pluginPermissionList', list: result })); } catch (ex) { }
4832 + });
4833 + });
4834 + });
4835 + });
4836 + break;
4837 + }
4838 case 'getpluginversions': {
4839 if ((user.siteadmin != SITERIGHT_ADMIN) || (parent.parent.pluginHandler == null)) break; // Must be full admin with plugins enabled
4840 parent.parent.pluginHandler.getPluginVersions(command.id)
pluginHandler.js
+352 -9
@@ -94,25 +94,25 @@ module.exports.pluginHandler = function (parent) {
94 if (typeof pluginRegInfo == 'function') d = pluginRegInfo();
95 else d = pluginRegInfo;
96 if (d.tabId == null || d.tabTitle == null) { return false; }
97 - if (!Q(d.tabId)) {
97 + if (!document.getElementById(d.tabId)) {
98 var defaultOn = 'class="on"';
99 - if (Q('p19headers').querySelectorAll("span.on").length) defaultOn = '';
100 - QA('p19headers', '<span ' + defaultOn + ' id="p19ph-' + d.tabId + '" onclick="return pluginHandler.callPluginPage(\\''+d.tabId+'\\', this);">'+d.tabTitle+'</span>');
101 - QA('p19pages', '<div id="' + d.tabId + '"></div>');
99 + if (document.getElementById('p19headers').querySelectorAll("span.on").length) defaultOn = '';
100 + document.getElementById('p19headers').innerHTML += '<span ' + defaultOn + ' id="p19ph-' + d.tabId + '" onclick="return pluginHandler.callPluginPage(\\''+d.tabId+'\\', this);">'+d.tabTitle+'</span>';
101 + document.getElementById('p19pages').innerHTML += '<div id="' + d.tabId + '"></div>';
102 }
103 - QV('MainDevPlugins', true);
103 + document.getElementById('MainDevPlugins').style.display = '';
104 };
105 obj.callPluginPage = function(id, el) {
106 - var pages = Q('p19pages').querySelectorAll("#p19pages>div");
106 + var pages = document.getElementById('p19pages').querySelectorAll("#p19pages>div");
107 for (const i of pages) { i.style.display = 'none'; }
108 - QV(id, true);
109 - var tabs = Q('p19headers').querySelectorAll("span");
108 + document.getElementById(id).style.display = '';
109 + var tabs = document.getElementById('p19headers').querySelectorAll("span");
110 for (const i of tabs) { i.classList.remove('on'); }
111 el.classList.add('on');
112 putstore('_curPluginPage', id);
113 };
114 obj.addPluginEx = function() {
115 - meshserver.send({ action: 'addplugin', url: Q('pluginurlinput').value});
115 + meshserver.send({ action: 'addplugin', url: document.getElementById('pluginurlinput').value});
116 };
117 obj.addPluginDlg = function() {
118 if (typeof showModal === 'function') {
@@ -590,6 +590,349 @@ module.exports.pluginHandler = function (parent) {
590 });
591 };
592
593 + // Reload a specific plugin without restarting the server
594 + // Useful for development and upgrading - call this after modifying plugin files
595 + obj.reloadPlugin = function (pluginName, func) {
596 + var pluginPath = obj.pluginPath + '/' + pluginName;
597 + var mainFile = pluginPath + '/' + pluginName + '.js';
598 +
599 + if (!obj.fs.existsSync(mainFile)) {
600 + var errMsg = "Plugin not found: " + pluginName;
601 + console.log(errMsg);
602 + if (func) func({ success: false, error: errMsg });
603 + return;
604 + }
605 +
606 + // Clear the require cache for this plugin
607 + var resolvedPath = require.resolve(mainFile);
608 + if (require.cache[resolvedPath]) {
609 + delete require.cache[resolvedPath];
610 + }
611 +
612 + // Also try to clear any nested requires (basic approach)
613 + Object.keys(require.cache).forEach(function (key) {
614 + if (key.startsWith(pluginPath + '/')) {
615 + delete require.cache[key];
616 + }
617 + });
618 +
619 + // Remove old plugin instance
620 + delete obj.plugins[pluginName];
621 + delete obj.exports[pluginName];
622 +
623 + // Reload the plugin
624 + try {
625 + obj.plugins[pluginName] = require(mainFile)[pluginName](obj);
626 + obj.exports[pluginName] = obj.plugins[pluginName].exports;
627 +
628 + // Call server_startup hook if it exists (re-initializes the plugin)
629 + if (typeof obj.plugins[pluginName].server_startup === 'function') {
630 + obj.plugins[pluginName].server_startup();
631 + }
632 +
633 + console.log("Plugin reloaded successfully: " + pluginName);
634 + if (func) func({ success: true, name: pluginName });
635 + } catch (e) {
636 + var errMsg = "Error reloading plugin " + pluginName + ": " + e;
637 + console.log(errMsg, e.stack);
638 + if (func) func({ success: false, error: errMsg });
639 + }
640 + };
641 +
642 + // Reload all enabled plugins
643 + obj.reloadAllPlugins = function (func) {
644 + var results = [];
645 + var pluginNames = Object.keys(obj.plugins);
646 +
647 + if (pluginNames.length === 0) {
648 + if (func) func({ success: true, reloaded: [] });
649 + return;
650 + }
651 +
652 + pluginNames.forEach(function (pluginName) {
653 + obj.reloadPlugin(pluginName, function (result) {
654 + results.push(result);
655 + if (results.length === pluginNames.length) {
656 + if (func) func({ success: true, reloaded: results });
657 + }
658 + });
659 + });
660 + };
661 +
662 + // In-memory cache of registered permissions (loaded from plugins)
663 + obj.pluginPermissions = {};
664 + obj.pluginPermissionsCache = {}; // Loaded from database
665 +
666 + // Register a plugin's permissions (called by plugin during load)
667 + // permissions: { 'can_edit': { title: 'Edit', desc: 'Can edit', default: 'allowed' }, ... }
668 + // default value can be: 'allowed', 'denied', or 'inherited'
669 + obj.registerPermissions = function(pluginName, permissions) {
670 + var definitions = {};
671 + var defaults = {};
672 +
673 + for (var key in permissions) {
674 + definitions[key] = {
675 + title: permissions[key].title,
676 + desc: permissions[key].desc
677 + };
678 + defaults[key] = permissions[key].default || 'inherited';
679 + }
680 +
681 + obj.pluginPermissions[pluginName] = {
682 + definitions: definitions,
683 + defaults: defaults
684 + };
685 + //console.log("Registered permissions for plugin: " + pluginName);
686 + };
687 +
688 + // Helper to resolve meshId from nodeId (async)
689 + obj.resolveMeshFromNode = function(nodeId) {
690 + return new Promise(function(resolve, reject) {
691 + parent.db.Get(nodeId, function(err, node) {
692 + if (err || !node) {
693 + resolve(null);
694 + } else {
695 + resolve(node[0].meshid);
696 + }
697 + });
698 + });
699 + };
700 +
701 + // Helper: do the actual permission check (sync)
702 + function doCheckPluginPermission(user, pluginName, permission, nodeId, meshId) {
703 + return obj.checkPluginPermission(user, pluginName, permission, nodeId, meshId);
704 + }
705 +
706 + // New API: Get all permissions for a user/context
707 + // Always returns a Promise. Returns an array of permission keys the user has access to.
708 + // Usage: const perms = await parent.getAccessPermissions('pluginName', user, { nodeid: 'node/...' })
709 + // Returns: ['can_access', 'can_edit', ...]
710 + obj.getAccessPermissions = function(pluginName, user, context) {
711 + var nodeId = null;
712 + var meshId = null;
713 +
714 + if (typeof context === 'string') {
715 + nodeId = context;
716 + } else if (typeof context === 'object') {
717 + nodeId = context.nodeId || context.nodeid;
718 + meshId = context.meshId || context.meshid || context.mesh;
719 + }
720 +
721 + // If we have nodeId but no meshId, resolve meshId from node
722 + var meshPromise;
723 + if (nodeId && !meshId) {
724 + meshPromise = obj.resolveMeshFromNode(nodeId);
725 + } else {
726 + meshPromise = Promise.resolve(meshId);
727 + }
728 +
729 + return meshPromise.then(function(resolvedMeshId) {
730 + var pluginDef = obj.pluginPermissions[pluginName];
731 + var permKeys = pluginDef ? Object.keys(pluginDef.definitions) : [];
732 +
733 + var allowedPerms = [];
734 + for (var i = 0; i < permKeys.length; i++) {
735 + var permKey = permKeys[i];
736 + var allowed = doCheckPluginPermission(user, pluginName, permKey, nodeId, resolvedMeshId);
737 + if (allowed === true) {
738 + allowedPerms.push(permKey);
739 + }
740 + }
741 +
742 + // Return a function that checks individual permissions
743 + return function(permission) {
744 + if (permission == '_ALL_') return allowedPerms;
745 + return allowedPerms.indexOf(permission) >= 0;
746 + };
747 + });
748 + };
749 +
750 + obj.loadPluginPermissions = function(pluginName, callback) {
751 + parent.db.getPluginPermissions(pluginName, function(err, docs) {
752 + if (err || docs.length === 0) {
753 + // No permissions saved yet, create default structure
754 + obj.pluginPermissionsCache[pluginName] = {
755 + _id: 'pluginpermission//' + pluginName,
756 + pluginName: pluginName,
757 + permissions: {},
758 + defaults: obj.pluginPermissions[pluginName] ? obj.pluginPermissions[pluginName].defaults : {}
759 + };
760 + } else {
761 + obj.pluginPermissionsCache[pluginName] = docs[0];
762 + }
763 + if (callback) callback();
764 + });
765 + };
766 +
767 + obj.getPluginPermissions = function(pluginName) {
768 + var cached = obj.pluginPermissionsCache[pluginName];
769 + if (!cached) {
770 + // Return in-memory registration if no DB entry
771 + return obj.pluginPermissions[pluginName] || null;
772 + }
773 +
774 + // Merge definitions from plugin registration with saved permissions
775 + var definitions = obj.pluginPermissions[pluginName] ? obj.pluginPermissions[pluginName].definitions : {};
776 + return {
777 + _id: cached._id,
778 + pluginName: pluginName,
779 + definitions: definitions,
780 + defaults: cached.defaults || {},
781 + permissions: cached.permissions || {}
782 + };
783 + };
784 +
785 + obj.setPluginPermissions = function(pluginName, data, callback) {
786 + var existing = obj.pluginPermissionsCache[pluginName] || {};
787 +
788 + var doc = {
789 + _id: 'pluginpermission//' + pluginName,
790 + pluginName: pluginName,
791 + permissions: data.permissions || {},
792 + defaults: data.defaults || existing.defaults || {}
793 + };
794 +
795 + obj.pluginPermissionsCache[pluginName] = doc;
796 + parent.db.setPluginPermissions(pluginName, doc, function(err) {
797 + if (callback) callback(err);
798 + });
799 + };
800 +
801 + function userIsInGroup(user, groupId) {
802 + if (!user || !user.links) return false;
803 + return user.links[groupId] != null;
804 + }
805 +
806 + // Evaluate if user has access at a specific level (global, mesh override, node override)
807 + // Returns: 'allowed', 'denied', or 'inherited' (not set)
808 + function evaluateAccessLevel(entry, user) {
809 + if (!entry) return 'inherited';
810 +
811 + var allowed = entry.allowed || {};
812 + var denied = entry.denied || {};
813 +
814 + // Check allowed lists first
815 + if (allowed.users && allowed.users.indexOf(user._id) >= 0) return 'allowed';
816 + if (allowed.userGroups) {
817 + for (var i = 0; i < allowed.userGroups.length; i++) {
818 + if (userIsInGroup(user, allowed.userGroups[i])) return 'allowed';
819 + }
820 + }
821 +
822 + // Check denied lists
823 + if (denied.users && denied.users.indexOf(user._id) >= 0) return 'denied';
824 + if (denied.userGroups) {
825 + for (var i = 0; i < denied.userGroups.length; i++) {
826 + if (userIsInGroup(user, denied.userGroups[i])) return 'denied';
827 + }
828 + }
829 +
830 + return 'inherited';
831 + }
832 +
833 + // Core permission check function
834 + // user: user object from MeshCentral
835 + // pluginName: string, e.g., 'regedit'
836 + // permission: string, e.g., 'can_edit'
837 + // nodeId: optional node ID to check node-specific permissions
838 + // meshId: optional mesh ID (if not provided, derived from node)
839 + obj.checkPluginPermission = function(user, pluginName, permission, nodeId, meshId) {
840 + // 1. Full admin always has access
841 + if (user.siteadmin === 0xFFFFFFFF) return true;
842 +
843 + // 2. Get plugin permissions config
844 + var config = obj.getPluginPermissions(pluginName);
845 + if (!config) {
846 + // No permissions defined, allow by default (backwards compatibility)
847 + return true;
848 + }
849 +
850 + // 3. Get permissions for this specific permission key
851 + var permConfig = config.permissions ? config.permissions[permission] : null;
852 + if (!permConfig) {
853 + permConfig = {
854 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
855 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
856 + meshOverrides: {},
857 + nodeOverrides: {}
858 + };
859 + }
860 +
861 + // 4. Resolve mesh if we have a node but no mesh
862 + var targetMesh = meshId;
863 + var targetNode = nodeId;
864 +
865 + if (targetNode && !targetMesh) {
866 + // Try to get mesh from node cache
867 + // MeshCentral typically stores nodes at parent.nodes
868 + var node = null;
869 +
870 + // Try to get node from parent.nodes
871 + if (obj.parent.nodes && obj.parent.nodes[targetNode]) {
872 + node = obj.parent.nodes[targetNode];
873 + } else if (parent.meshes) {
874 + // Check each mesh's nodes
875 + for (var mid in parent.meshes) {
876 + var mesh = parent.meshes[mid];
877 + if (mesh.nodes && mesh.nodes[targetNode]) {
878 + node = mesh.nodes[targetNode];
879 + break;
880 + }
881 + }
882 + }
883 +
884 + if (node && node.meshid) {
885 + targetMesh = node.meshid;
886 + }
887 + }
888 +
889 + // 5. Check cascade: Node → Mesh → Global → Default
890 +
891 + // A) Check node-specific (highest priority)
892 + if (targetNode && permConfig.nodeOverrides && permConfig.nodeOverrides[targetNode]) {
893 + var result = evaluateAccessLevel(permConfig.nodeOverrides[targetNode], user);
894 + if (result !== 'inherited') return result === 'allowed';
895 + }
896 +
897 + // B) Check mesh-specific
898 + if (targetMesh && permConfig.meshOverrides && permConfig.meshOverrides[targetMesh]) {
899 + // Verify user has access to this mesh before applying mesh override
900 + // User has mesh access if they have a link to the mesh
901 + var userHasMeshAccess = (user.links && user.links[targetMesh]) ? true : false;
902 +
903 + if (userHasMeshAccess) {
904 + var result = evaluateAccessLevel(permConfig.meshOverrides[targetMesh], user);
905 + if (result !== 'inherited') return result === 'allowed';
906 + }
907 + }
908 +
909 + // C) Check global level
910 + var globalResult = evaluateAccessLevel(permConfig, user);
911 + if (globalResult !== 'inherited') return globalResult === 'allowed';
912 +
913 + // D) Fall back to default
914 + var defaultValue = config.defaults ? config.defaults[permission] : 'inherited';
915 + if (defaultValue === 'inherited') {
916 + // If default is also inherited, use 'allowed' as safe fallback
917 + defaultValue = 'allowed';
918 + }
919 + return defaultValue === 'allowed';
920 + };
921 +
922 + obj.initPluginPermissions = function() {
923 + parent.db.getPlugins(function(err, plugins) {
924 + if (err || !plugins) return;
925 + plugins.forEach(function(plugin) {
926 + if (plugin.status === 1 && plugin.shortName) {
927 + obj.loadPluginPermissions(plugin.shortName);
928 + }
929 + });
930 + });
931 + };
932 +
933 + // Call init on load
934 + obj.initPluginPermissions();
935 +
936 obj.handleAdminReq = function (req, res, user, serv) {
937 if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
938 var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
views/default.handlebars
+671 -8
@@ -3899,6 +3899,22 @@
3899 }
3900 break;
3901 }
3902 + case 'pluginPermissions': {
3903 + handlePluginPermissions(message);
3904 + break;
3905 + }
3906 + case 'pluginPermissionsSet': {
3907 + if (message.success) {
3908 + // Already handled in save function
3909 + } else {
3910 + alert("Error saving permissions: " + message.error);
3911 + }
3912 + break;
3913 + }
3914 + case 'pluginPermissionList': {
3915 + handlePluginPermissionList(message);
3916 + break;
3917 + }
3918 case 'createInviteLink': { // Agent installation invitation link
3919 if (xxdialogTag != message.meshid) break;
3920 var servername = serverinfo.name;
@@ -19119,7 +19135,7 @@
19135 if (installedPluginList['version_info'] == null) installedPluginList['version_info'] = [];
19136 installedPluginList['version_info'][versInfo.id] = versInfo;
19137 }
19122 - var tr = Q('p42tbl').querySelectorAll('.p42tblRow');
19138 + var tr = document.getElementById('p42tbl').querySelectorAll('.p42tblRow');
19139 if (tr.length) {
19140 for (var i in Object.values(tr)) {
19141 tr[i].parentNode.removeChild(tr[i]);
@@ -19143,12 +19159,14 @@
19159 1: {
19160 'disable': 'Disable',
19161 'upgrade': 'Upgrade',
19162 + 'reload': 'Reload',
19163 + 'permissions': 'Permissions',
19164 // 'downgrade': 'Downgrade' // disabling until plugins have prior versions available for better testing
19165 }
19166 };
19167 var vers_not_compat = ' [ <span onclick="return setDialogMode(2, \'Compatibility Issue\', 1, null, \'This plugin version is not compatible with your MeshCentral installation, please upgrade MeshCentral first.\');" title="' + "Version incompatible, please upgrade your MeshCentral installation first" + '" style="cursor: pointer; color:red;"> ! </span> ]';
19168
19151 - var tbl = Q('p42tbl');
19169 + var tbl = document.getElementById('p42tbl');
19170 installedPluginList.forEach(function(p){
19171 var cant_action = [];
19172 if (p.hasAdminPanel == true && p.status) {
@@ -19195,7 +19213,7 @@
19213 tr.setAttribute('id', 'pluginRow-' + p._id);
19214 });
19215 } else {
19198 - var tr = Q('p42tbl').querySelectorAll('.p42tblRow');
19216 + var tr = document.getElementById('p42tbl').querySelectorAll('.p42tblRow');
19217 for (var i in Object.values(tr)) { tr[i].parentNode.removeChild(tr[i]); }
19218 }
19219 if (versInfo == null) refreshPluginLatest();
@@ -19209,12 +19227,12 @@
19227 function distributeCore() {
19228 if (pluginHandler == null) return;
19229 meshserver.send({ action: 'distributeCore', nodes: nodes }); // All nodes the user has access to
19212 - QV('pluginRestartNotice', false);
19230 + document.getElementById('pluginRestartNotice').style.display = 'none';
19231 }
19232
19233 function pluginActionEx() {
19234 if (pluginHandler == null) return;
19217 - var act = Q('lastPluginAct').value, id = Q('lastPluginId').value, pVersUrl = Q('lastPluginVersion').value;
19235 + var act = document.getElementById('lastPluginAct').value, id = document.getElementById('lastPluginId').value, pVersUrl = document.getElementById('lastPluginVersion').value;
19236
19237 switch(act) {
19238 case 'upgrade':
@@ -19222,7 +19240,7 @@
19240 meshserver.send({ 'action': 'installplugin', 'id': id, 'version_only': false });
19241 break;
19242 case 'downgrade':
19225 - Q('lastPluginVersion').querySelectorAll('option').forEach(function(opt) {
19243 + document.getElementById('lastPluginVersion').querySelectorAll('option').forEach(function(opt) {
19244 if (opt.value == pVersUrl) pVers = opt.text;
19245 });
19246 meshserver.send({ 'action': 'installplugin', 'id': id, 'version_only': { 'name': pVers, 'url': pVersUrl }});
@@ -19233,12 +19251,31 @@
19251 case 'disable':
19252 meshserver.send({ 'action': 'disableplugin', 'id': id });
19253 break;
19254 + case 'reload':
19255 + var plugin = null;
19256 + for (var i in installedPluginList) { if (installedPluginList[i]._id == id) { plugin = installedPluginList[i]; } }
19257 + if (plugin) {
19258 + meshserver.send({ 'action': 'reloadplugin', 'plugin': plugin.shortName });
19259 + }
19260 + break;
19261 }
19237 - QV('pluginRestartNotice', true);
19262 + document.getElementById('pluginRestartNotice').style.display = 'none';
19263 }
19264
19265 function pluginAction(elem, id) {
19266 if (pluginHandler == null) return;
19267 +
19268 + // Special handling for permissions - opens matrix dialog
19269 + if (elem.value == 'permissions') {
19270 + var plugin = null;
19271 + for (var i in installedPluginList) { if (installedPluginList[i]._id == id) { plugin = installedPluginList[i]; } }
19272 + if (plugin) {
19273 + openPluginPermissionsDialog(plugin);
19274 + }
19275 + elem.value = '';
19276 + return;
19277 + }
19278 +
19279 if (elem.value == 'downgrade') {
19280 meshserver.send({ 'action': 'getpluginversions', 'id': id });
19281 } else {
@@ -19249,9 +19286,635 @@
19286 elem.value = '';
19287 }
19288
19289 + // Plugin Permissions Dialog - Custom Modal (same as default3)
19290 + var currentPluginPermissions = null;
19291 + var permissionListData = null;
19292 + var permissionState = {}; // JavaScript state for permissions
19293 +
19294 + function openPluginPermissionsDialog(plugin) {
19295 + meshserver.send({ action: 'getpluginpermissions', plugin: plugin.shortName });
19296 + meshserver.send({ action: 'getpluginpermissionlist' });
19297 +
19298 + currentPluginPermissions = { plugin: plugin.shortName, name: plugin.name };
19299 +
19300 + // Create custom modal
19301 + var modalHtml = '<div id="pluginPermModal" style="display:none; position:fixed; z-index:10000; left:0; top:0; width:100%; height:100%; background-color:rgba(0,0,0,0.5);">';
19302 + modalHtml += '<div style="margin:30px auto; max-width:900px; background:#fff; border-radius:8px;">';
19303 + modalHtml += '<div style="padding:15px 20px; border-bottom:1px solid #dee2e6; display:flex; justify-content:space-between; align-items:center;">';
19304 + modalHtml += '<h5 style="margin:0;">Plugin Permissions - ' + EscapeHtml(plugin.name) + '</h5>';
19305 + modalHtml += '<button type="button" onclick="closePluginPermModal()" style="background:none; border:none; font-size:20px; cursor:pointer;">&times;</button>';
19306 + modalHtml += '</div>';
19307 + modalHtml += '<div id="pluginPermBody" style="padding:20px; max-height:70vh; overflow-y:auto;">';
19308 + modalHtml += '<div style="text-align:center; padding:40px;">Loading permissions...</div>';
19309 + modalHtml += '</div>';
19310 + modalHtml += '<div style="padding:15px 20px; border-top:1px solid #dee2e6; display:flex; justify-content:space-between; align-items:center;">';
19311 + modalHtml += '<div style="font-size:12px; color:#666;">Permissions cascade: Node → Mesh → Global → Default</div>';
19312 + modalHtml += '<div>';
19313 + modalHtml += '<button type="button" style="margin-right:10px; padding:5px 15px;" onclick="closePluginPermModal()">Cancel</button>';
19314 + modalHtml += '<button type="button" style="padding:5px 15px; background:#007bff; color:#fff; border:none; border-radius:4px;" onclick="savePluginPermissionsEx()">Save</button>';
19315 + modalHtml += '</div>';
19316 + modalHtml += '</div></div></div>';
19317 +
19318 + document.body.insertAdjacentHTML('beforeend', modalHtml);
19319 + document.getElementById('pluginPermModal').style.display = 'block';
19320 + document.body.style.overflow = 'hidden';
19321 + }
19322 +
19323 + function closePluginPermModal() {
19324 + var modal = document.getElementById('pluginPermModal');
19325 + if (modal) {
19326 + modal.remove();
19327 + document.body.style.overflow = '';
19328 + }
19329 + }
19330 +
19331 + function handlePluginPermissions(msg) {
19332 + if (msg.action != 'pluginPermissions') return;
19333 + currentPluginPermissions.data = msg.permissions;
19334 + renderPermissionMatrixEx();
19335 + }
19336 +
19337 + function handlePluginPermissionList(msg) {
19338 + if (msg.action != 'pluginPermissionList') return;
19339 + permissionListData = msg.list;
19340 + renderPermissionMatrixEx();
19341 + }
19342 +
19343 + function renderPermissionMatrixEx() {
19344 + if (!currentPluginPermissions || !currentPluginPermissions.data || !permissionListData) return;
19345 +
19346 + var data = currentPluginPermissions.data;
19347 + var definitions = data.definitions || {};
19348 + var permissions = data.permissions || {};
19349 + var defaults = data.defaults || {};
19350 +
19351 + // Check if plugin has no permissions defined
19352 + if (Object.keys(definitions).length === 0) {
19353 + var body = document.getElementById('pluginPermBody');
19354 + if (body) {
19355 + body.innerHTML = '<div style="padding:20px;">This plugin has not defined any permissions.</div>';
19356 + }
19357 + return;
19358 + }
19359 +
19360 + // Initialize permissionState from loaded data
19361 + permissionState = {};
19362 + window.meshOverridesState = {};
19363 + window.nodeOverridesState = {};
19364 + for (var permKey in definitions) {
19365 + var permData = permissions[permKey] || {
19366 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
19367 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
19368 + meshOverrides: {},
19369 + nodeOverrides: {}
19370 + };
19371 + permissionState[permKey] = {
19372 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
19373 + denied: { users: [], userGroups: [], meshes: [], nodes: [] }
19374 + };
19375 + // Copy allowed
19376 + if (permData.allowed) {
19377 + if (permData.allowed.users) permissionState[permKey].allowed.users = permData.allowed.users.slice();
19378 + if (permData.allowed.userGroups) permissionState[permKey].allowed.userGroups = permData.allowed.userGroups.slice();
19379 + if (permData.allowed.meshes) permissionState[permKey].allowed.meshes = permData.allowed.meshes.slice();
19380 + if (permData.allowed.nodes) permissionState[permKey].allowed.nodes = permData.allowed.nodes.slice();
19381 + }
19382 + // Copy denied
19383 + if (permData.denied) {
19384 + if (permData.denied.users) permissionState[permKey].denied.users = permData.denied.users.slice();
19385 + if (permData.denied.userGroups) permissionState[permKey].denied.userGroups = permData.denied.userGroups.slice();
19386 + if (permData.denied.meshes) permissionState[permKey].denied.meshes = permData.denied.meshes.slice();
19387 + if (permData.denied.nodes) permissionState[permKey].denied.nodes = permData.denied.nodes.slice();
19388 + }
19389 +
19390 + // Initialize meshOverridesState and nodeOverridesState from loaded data
19391 + window.meshOverridesState[permKey] = permData.meshOverrides || {};
19392 + window.nodeOverridesState[permKey] = permData.nodeOverrides || {};
19393 + }
19394 +
19395 + var x = '';
19396 +
19397 + // Custom tab buttons
19398 + x += '<div style="margin-bottom:15px; border-bottom:1px solid #dee2e6;">';
19399 + x += '<button class="btn btn-sm active" id="btnTabGlobal" style="margin-right:5px; background:#007bff; color:#fff;" onclick="switchPermTab(\'global\')">Global</button>';
19400 + x += '<button class="btn btn-sm" id="btnTabMeshes" style="margin-right:5px; background:#6c757d; color:#fff;" onclick="switchPermTab(\'meshes\')">Meshes</button>';
19401 + x += '<button class="btn btn-sm" id="btnTabNodes" style="background:#6c757d; color:#fff;" onclick="switchPermTab(\'nodes\')">Nodes</button>';
19402 + x += '</div>';
19403 +
19404 + x += '<div id="panel-global">';
19405 + x += buildPermissionSection(definitions, permissions, defaults, 'global', null);
19406 + x += '</div>';
19407 +
19408 + x += '<div id="panel-meshes" style="display:none;">';
19409 + x += buildMeshNodeSection(definitions, permissions, 'mesh');
19410 + x += '</div>';
19411 +
19412 + x += '<div id="panel-nodes" style="display:none;">';
19413 + x += buildMeshNodeSection(definitions, permissions, 'node');
19414 + x += '</div>';
19415 +
19416 + x += '<input type="hidden" id="permPluginShortName" value="' + currentPluginPermissions.plugin + '" />';
19417 +
19418 + document.getElementById('pluginPermBody').innerHTML = x;
19419 + }
19420 +
19421 + function switchPermTab(tab) {
19422 + document.getElementById('panel-global').style.display = 'none';
19423 + document.getElementById('panel-meshes').style.display = 'none';
19424 + document.getElementById('panel-nodes').style.display = 'none';
19425 +
19426 + document.getElementById('btnTabGlobal').style.background = '#6c757d';
19427 + document.getElementById('btnTabMeshes').style.background = '#6c757d';
19428 + document.getElementById('btnTabNodes').style.background = '#6c757d';
19429 +
19430 + document.getElementById('panel-' + tab).style.display = 'block';
19431 + document.getElementById('btnTab' + tab.charAt(0).toUpperCase() + tab.slice(1)).style.background = '#007bff';
19432 + }
19433 +
19434 + function buildPermissionSection(definitions, permissions, defaults, level, parentId, overrideData) {
19435 + var x = '';
19436 +
19437 + var idx = 0;
19438 + for (var permKey in definitions) {
19439 + var def = definitions[permKey];
19440 + var permData = permissions[permKey] || {
19441 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
19442 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
19443 + meshOverrides: {},
19444 + nodeOverrides: {}
19445 + };
19446 + var defaultVal = defaults[permKey] || 'inherited';
19447 +
19448 + var collapseId = 'permCollapse-' + level + '-' + permKey + (parentId ? '-' + parentId : '');
19449 + var isFirst = idx === 0;
19450 +
19451 + // Determine which data to use: overrideData (for mesh/node) or permissionState (for global)
19452 + var useData;
19453 + if (overrideData && overrideData[permKey]) {
19454 + useData = overrideData[permKey];
19455 + } else if (permissionState[permKey]) {
19456 + useData = permissionState[permKey];
19457 + } else {
19458 + useData = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
19459 + }
19460 +
19461 + x += '<div style="border:1px solid #dee2e6; border-radius:8px; margin-bottom:10px; overflow:hidden;">';
19462 + x += '<div style="padding:12px 15px; background:#f8f9fa; display:flex; justify-content:space-between; align-items:center; cursor:pointer;" onclick="togglePermCollapse(\'' + collapseId + '\')">';
19463 + x += '<div><strong>' + EscapeHtml(def.title || permKey) + '</strong><br><small style="color:#666;">' + EscapeHtml(def.desc || '') + '</small></div>';
19464 + x += '<div><select style="padding:3px;" id="perm_default_' + permKey + '" onclick="event.stopPropagation()">';
19465 + x += '<option value="allowed"' + (defaultVal === 'allowed' ? ' selected' : '') + '>✓ Allowed</option>';
19466 + x += '<option value="denied"' + (defaultVal === 'denied' ? ' selected' : '') + '>✗ Denied</option>';
19467 + x += '<option value="inherited"' + (defaultVal === 'inherited' ? ' selected' : '') + '>↩ Inherited</option>';
19468 + x += '</select></div>';
19469 + x += '</div>';
19470 + x += '<div id="' + collapseId + '" style="padding:15px;' + (isFirst ? '' : 'display:none;') + '">';
19471 +
19472 + x += '<div style="display:flex; gap:20px; margin-bottom:15px;">';
19473 + x += '<div style="flex:1;">';
19474 + x += '<div style="margin-bottom:5px;"><span style="background:#28a745; color:#fff; padding:2px 8px; border-radius:3px; font-size:12px;">Allowed</span></div>';
19475 + x += buildAutocompleteSection(permKey, level, 'allowed', parentId, useData ? useData.allowed : { users: [], userGroups: [], meshes: [], nodes: [] });
19476 + x += '</div>';
19477 + x += '<div style="flex:1;">';
19478 + x += '<div style="margin-bottom:5px;"><span style="background:#dc3545; color:#fff; padding:2px 8px; border-radius:3px; font-size:12px;">Denied</span></div>';
19479 + x += buildAutocompleteSection(permKey, level, 'denied', parentId, useData ? useData.denied : { users: [], userGroups: [], meshes: [], nodes: [] });
19480 + x += '</div>';
19481 + x += '</div>';
19482 +
19483 + x += '</div></div>';
19484 + idx++;
19485 + }
19486 +
19487 + return x;
19488 + }
19489 +
19490 + function togglePermCollapse(id) {
19491 + var el = document.getElementById(id);
19492 + el.style.display = el.style.display === 'none' ? 'block' : 'none';
19493 + }
19494 +
19495 + function buildAutocompleteSection(permKey, level, accessType, parentId, currentData) {
19496 + var inputId = 'perm_input_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19497 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19498 + var containerId = 'perm_tags_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19499 +
19500 + var x = '<div style="position:relative;">';
19501 + x += '<input type="text" id="' + inputId + '" placeholder="Search..." style="width:100%; padding:5px;" ';
19502 + x += 'onkeyup="searchPermEntities(this, \'' + permKey + '\', \'' + level + '\', \'' + accessType + '\', \'' + (parentId || '') + '\')" ';
19503 + x += 'onfocus="showPermDropdown(\'' + listId + '\')">';
19504 + x += '<div id="' + listId + '" style="position:relative; background:#fff; border:1px solid #ccc; border-top:none; max-height:200px; overflow-y:auto; display:none; cursor:pointer;"></div>';
19505 + x += '</div>';
19506 +
19507 + x += '<div id="' + containerId + '" style="margin-top:5px; display:flex; flex-wrap:wrap; gap:5px;">';
19508 +
19509 + var allEntities = [];
19510 + if (permissionListData.users) allEntities = allEntities.concat(permissionListData.users);
19511 + if (permissionListData.userGroups) allEntities = allEntities.concat(permissionListData.userGroups);
19512 + if (permissionListData.meshes) allEntities = allEntities.concat(permissionListData.meshes);
19513 + if (permissionListData.nodes) allEntities = allEntities.concat(permissionListData.nodes);
19514 +
19515 + var selectedIds = currentData.users || [];
19516 + selectedIds.forEach(function(id) {
19517 + var entity = allEntities.find(function(e) { return e._id === id; });
19518 + if (entity) x += buildPermTag(entity, permKey, level, accessType, 'user', parentId);
19519 + });
19520 +
19521 + var selectedGroups = currentData.userGroups || [];
19522 + selectedGroups.forEach(function(id) {
19523 + var entity = allEntities.find(function(e) { return e._id === id; });
19524 + if (entity) x += buildPermTag(entity, permKey, level, accessType, 'userGroup', parentId);
19525 + });
19526 +
19527 + x += '</div>';
19528 +
19529 + return x;
19530 + }
19531 +
19532 + function buildPermTag(entity, permKey, level, accessType, entityType, parentId) {
19533 + var icon = entity._id.startsWith('user/') ? '👤' : (entity._id.startsWith('ugrp/') ? '👥' : (entity._id.startsWith('mesh/') ? '🖥️' : '💻'));
19534 + var tagId = 'perm_tag_' + entity._id.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
19535 + var safeId = entity._id.replace(/'/g, '___');
19536 +
19537 + return '<span style="background:#6c757d; color:#fff; padding:3px 8px; border-radius:3px; font-size:12px;" id="' + tagId + '" data-actualid="' + EscapeHtml(entity._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + entityType + '" data-parentid="' + (parentId || '') + '">' + icon + ' ' + EscapeHtml(entity.name || entity._id) + ' <span style="cursor:pointer; margin-left:5px;" onclick="removePermTagFromTag(this)">×</span></span>';
19538 + }
19539 +
19540 + function removePermTagFromTag(el) {
19541 + var span = el.parentElement;
19542 + removePermTag(span.getAttribute('data-actualid'), span.getAttribute('data-perm'), span.getAttribute('data-level'), span.getAttribute('data-access'), span.getAttribute('data-type'), span.getAttribute('data-parentid') || '');
19543 + }
19544 +
19545 + function searchPermEntities(input, permKey, level, accessType, parentId) {
19546 + var query = input.value.toLowerCase();
19547 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19548 + var list = document.getElementById(listId);
19549 + if (!list) return;
19550 +
19551 + if (query.length < 1) {
19552 + list.style.display = 'none';
19553 + return;
19554 + }
19555 +
19556 + // Get currently selected IDs to filter out
19557 + var selectedIds = [];
19558 + if (permissionState[permKey] && permissionState[permKey][accessType]) {
19559 + var state = permissionState[permKey][accessType];
19560 + if (state.users) selectedIds = selectedIds.concat(state.users);
19561 + if (state.userGroups) selectedIds = selectedIds.concat(state.userGroups);
19562 + if (state.meshes) selectedIds = selectedIds.concat(state.meshes);
19563 + if (state.nodes) selectedIds = selectedIds.concat(state.nodes);
19564 + }
19565 +
19566 + // Also check mesh/node overrides for the current context
19567 + if (level === 'mesh' && parentId && window.meshOverridesState && window.meshOverridesState[permKey] && window.meshOverridesState[permKey][parentId] && window.meshOverridesState[permKey][parentId][accessType]) {
19568 + var meshState = window.meshOverridesState[permKey][parentId][accessType];
19569 + if (meshState.users) selectedIds = selectedIds.concat(meshState.users);
19570 + if (meshState.userGroups) selectedIds = selectedIds.concat(meshState.userGroups);
19571 + if (meshState.meshes) selectedIds = selectedIds.concat(meshState.meshes);
19572 + if (meshState.nodes) selectedIds = selectedIds.concat(meshState.nodes);
19573 + }
19574 + if (level === 'node' && parentId && window.nodeOverridesState && window.nodeOverridesState[permKey] && window.nodeOverridesState[permKey][parentId] && window.nodeOverridesState[permKey][parentId][accessType]) {
19575 + var nodeState = window.nodeOverridesState[permKey][parentId][accessType];
19576 + if (nodeState.users) selectedIds = selectedIds.concat(nodeState.users);
19577 + if (nodeState.userGroups) selectedIds = selectedIds.concat(nodeState.userGroups);
19578 + if (nodeState.meshes) selectedIds = selectedIds.concat(nodeState.meshes);
19579 + if (nodeState.nodes) selectedIds = selectedIds.concat(nodeState.nodes);
19580 + }
19581 +
19582 + var results = [];
19583 + var groupedNodes = {};
19584 +
19585 + if (permissionListData.users) {
19586 + permissionListData.users.forEach(function(u) {
19587 + if (selectedIds.indexOf(u._id) >= 0) return;
19588 + if ((u.name && u.name.toLowerCase().includes(query)) || (u.email && u.email.toLowerCase().includes(query))) {
19589 + results.push({ _id: u._id, name: u.name || u.email, type: 'user', icon: '👤' });
19590 + }
19591 + });
19592 + }
19593 +
19594 + if (permissionListData.userGroups) {
19595 + permissionListData.userGroups.forEach(function(ug) {
19596 + if (selectedIds.indexOf(ug._id) >= 0) return;
19597 + if (ug.name && ug.name.toLowerCase().includes(query)) {
19598 + results.push({ _id: ug._id, name: ug.name, type: 'userGroup', icon: '👥' });
19599 + }
19600 + });
19601 + }
19602 +
19603 + if (level === 'global' && permissionListData.meshes) {
19604 + permissionListData.meshes.forEach(function(m) {
19605 + if (selectedIds.indexOf(m._id) >= 0) return;
19606 + if (m.name && m.name.toLowerCase().includes(query)) {
19607 + results.push({ _id: m._id, name: m.name, type: 'mesh', icon: '🖥️' });
19608 + }
19609 + });
19610 + }
19611 +
19612 + if (level === 'node' && permissionListData.nodes) {
19613 + permissionListData.nodes.forEach(function(n) {
19614 + if (selectedIds.indexOf(n._id) >= 0) return;
19615 + if (n.name && n.name.toLowerCase().includes(query)) {
19616 + var meshGroup = n.meshname || 'Ungrouped';
19617 + if (!groupedNodes[meshGroup]) groupedNodes[meshGroup] = [];
19618 + groupedNodes[meshGroup].push({ _id: n._id, name: n.name, type: 'node', icon: '💻', meshname: meshGroup });
19619 + }
19620 + });
19621 + }
19622 +
19623 + list.innerHTML = '';
19624 +
19625 + // Add individual results first
19626 + results.slice(0, 10).forEach(function(r) {
19627 + var safeId = r._id.replace(/'/g, '___');
19628 + list.innerHTML += '<div class="perm-result-item" style="padding:8px 12px; cursor:pointer;" data-id="' + safeId + '" data-actualid="' + EscapeHtml(r._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + r.type + '" data-name="' + EscapeHtml(r.name) + '" data-icon="' + r.icon + '" data-parentid="' + (parentId || '') + '" onclick="addPermEntityFromDropdown(this)">' + r.icon + ' ' + EscapeHtml(r.name) + ' <small style="color:#666;">(' + r.type + ')</small></div>';
19629 + });
19630 +
19631 + // Add grouped nodes at the end
19632 + if (level === 'node') {
19633 + for (var meshName in groupedNodes) {
19634 + list.innerHTML += '<div style="padding:8px 12px; background:#eee; font-weight:bold; font-size:11px;">🖥️ ' + EscapeHtml(meshName) + '</div>';
19635 + groupedNodes[meshName].forEach(function(n) {
19636 + var safeId = n._id.replace(/'/g, '___');
19637 + list.innerHTML += '<div class="perm-result-item" style="padding:8px 12px; cursor:pointer; padding-left:20px;" data-id="' + safeId + '" data-actualid="' + EscapeHtml(n._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + n.type + '" data-name="' + EscapeHtml(n.name) + '" data-icon="' + n.icon + '" data-parentid="' + (parentId || '') + '" onclick="addPermEntityFromDropdown(this)">' + n.icon + ' ' + EscapeHtml(n.name) + '</div>';
19638 + });
19639 + }
19640 + }
19641 +
19642 + list.style.display = (results.length > 0 || (level === 'node' && Object.keys(groupedNodes).length > 0)) ? 'block' : 'none';
19643 + }
19644 +
19645 + function addPermEntityFromDropdown(el) {
19646 + var entityId = el.getAttribute('data-actualid');
19647 + var permKey = el.getAttribute('data-perm');
19648 + var level = el.getAttribute('data-level');
19649 + var accessType = el.getAttribute('data-access');
19650 + var entityType = el.getAttribute('data-type');
19651 + var entityName = el.getAttribute('data-name');
19652 + var entityIcon = el.getAttribute('data-icon');
19653 + var parentId = el.getAttribute('data-parentid') || '';
19654 + addPermEntity(entityId, permKey, level, accessType, entityType, parentId, entityName, entityIcon);
19655 + }
19656 +
19657 + function showPermDropdown(listId) {
19658 + var list = document.getElementById(listId);
19659 + if (list && list.children.length > 0) {
19660 + list.style.display = 'block';
19661 + }
19662 + }
19663 +
19664 + function addPermEntity(entityId, permKey, level, accessType, entityType, parentId, entityName, entityIcon) {
19665 + var containerId = 'perm_tags_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19666 + var container = document.getElementById(containerId);
19667 + var inputId = 'perm_input_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19668 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
19669 +
19670 + document.getElementById(inputId).value = '';
19671 + document.getElementById(listId).style.display = 'none';
19672 +
19673 + var existingTags = container.querySelectorAll('span');
19674 + for (var i = 0; i < existingTags.length; i++) {
19675 + if (existingTags[i].id && existingTags[i].id.includes(entityId.replace(/[^a-zA-Z0-9]/g, '_'))) {
19676 + return;
19677 + }
19678 + }
19679 +
19680 + var tagId = 'perm_tag_' + entityId.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
19681 + var tagHtml = '<span style="background:#6c757d; color:#fff; padding:3px 8px; border-radius:3px; font-size:12px;" id="' + tagId + '" data-actualid="' + EscapeHtml(entityId) + '">' + entityIcon + ' ' + EscapeHtml(entityName) + ' <span style="cursor:pointer; margin-left:5px;" onclick="removePermTag(\'' + entityId + '\', \'' + permKey + '\', \'' + level + '\', \'' + accessType + '\', \'' + entityType + '\', \'' + (parentId || '') + '\')">×</span></span>';
19682 + container.insertAdjacentHTML('beforeend', tagHtml);
19683 +
19684 + // Initialize overrides storage
19685 + if (!window.meshOverridesState) window.meshOverridesState = {};
19686 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
19687 + if (!window.meshOverridesState[permKey]) window.meshOverridesState[permKey] = {};
19688 + if (!window.nodeOverridesState[permKey]) window.nodeOverridesState[permKey] = {};
19689 +
19690 + var entityTypeKey = entityType === 'user' ? 'users' : (entityType === 'userGroup' ? 'userGroups' : (entityType === 'mesh' ? 'meshes' : (entityType === 'node' ? 'nodes' : null)));
19691 +
19692 + // Skip if entityTypeKey is invalid
19693 + if (entityTypeKey === null) {
19694 + console.log('Invalid entityType:', entityType);
19695 + return;
19696 + }
19697 +
19698 + // Update permissionState only for global level
19699 + if (level === 'global') {
19700 + if (permissionState[permKey] && permissionState[permKey][accessType] && permissionState[permKey][accessType][entityTypeKey]) {
19701 + if (permissionState[permKey][accessType][entityTypeKey].indexOf(entityId) === -1) {
19702 + permissionState[permKey][accessType][entityTypeKey].push(entityId);
19703 + }
19704 + }
19705 + }
19706 +
19707 + // Update mesh/node overrides if in context
19708 + if (level === 'mesh' && parentId) {
19709 + if (!window.meshOverridesState[permKey][parentId]) {
19710 + window.meshOverridesState[permKey][parentId] = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
19711 + }
19712 + // Ensure accessType object exists
19713 + if (!window.meshOverridesState[permKey][parentId][accessType]) {
19714 + window.meshOverridesState[permKey][parentId][accessType] = { users: [], userGroups: [], meshes: [], nodes: [] };
19715 + }
19716 + if (window.meshOverridesState[permKey][parentId][accessType][entityTypeKey] && window.meshOverridesState[permKey][parentId][accessType][entityTypeKey].indexOf(entityId) === -1) {
19717 + window.meshOverridesState[permKey][parentId][accessType][entityTypeKey].push(entityId);
19718 + }
19719 + }
19720 + if (level === 'node' && parentId) {
19721 + if (!window.nodeOverridesState[permKey][parentId]) {
19722 + window.nodeOverridesState[permKey][parentId] = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
19723 + }
19724 + // Ensure accessType object exists
19725 + if (!window.nodeOverridesState[permKey][parentId][accessType]) {
19726 + window.nodeOverridesState[permKey][parentId][accessType] = { users: [], userGroups: [], meshes: [], nodes: [] };
19727 + }
19728 + // Ensure entityTypeKey array exists
19729 + if (!window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey]) {
19730 + window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey] = [];
19731 + }
19732 + if (window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey] && window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey].indexOf(entityId) === -1) {
19733 + window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey].push(entityId);
19734 + }
19735 + }
19736 + }
19737 +
19738 + function removePermTag(entityId, permKey, level, accessType, entityType, parentId) {
19739 + var tagId = 'perm_tag_' + entityId.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
19740 + var tag = document.getElementById(tagId);
19741 + if (tag) tag.remove();
19742 +
19743 + // Initialize overrides storage
19744 + if (!window.meshOverridesState) window.meshOverridesState = {};
19745 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
19746 + if (!window.meshOverridesState[permKey]) window.meshOverridesState[permKey] = {};
19747 + if (!window.nodeOverridesState[permKey]) window.nodeOverridesState[permKey] = {};
19748 +
19749 + var entityTypeKey = entityType === 'user' ? 'users' : (entityType === 'userGroup' ? 'userGroups' : (entityType === 'mesh' ? 'meshes' : 'nodes'));
19750 +
19751 + // Remove from permissionState only for global level
19752 + if (level === 'global') {
19753 + if (permissionState[permKey] && permissionState[permKey][accessType] && permissionState[permKey][accessType][entityTypeKey]) {
19754 + var idx = permissionState[permKey][accessType][entityTypeKey].indexOf(entityId);
19755 + if (idx > -1) {
19756 + permissionState[permKey][accessType][entityTypeKey].splice(idx, 1);
19757 + }
19758 + }
19759 + }
19760 +
19761 + // Remove from mesh overrides if in mesh context
19762 + if (level === 'mesh' && parentId && window.meshOverridesState[permKey][parentId]) {
19763 + var override = window.meshOverridesState[permKey][parentId];
19764 + if (override[accessType] && override[accessType][entityTypeKey]) {
19765 + var idx3 = override[accessType][entityTypeKey].indexOf(entityId);
19766 + if (idx3 > -1) {
19767 + override[accessType][entityTypeKey].splice(idx3, 1);
19768 + }
19769 + }
19770 + }
19771 +
19772 + // Remove from node overrides if in node context
19773 + if (level === 'node' && parentId && window.nodeOverridesState[permKey][parentId]) {
19774 + var nodeOverride = window.nodeOverridesState[permKey][parentId];
19775 + if (nodeOverride[accessType] && nodeOverride[accessType][entityTypeKey]) {
19776 + var idx4 = nodeOverride[accessType][entityTypeKey].indexOf(entityId);
19777 + if (idx4 > -1) {
19778 + nodeOverride[accessType][entityTypeKey].splice(idx4, 1);
19779 + }
19780 + }
19781 + }
19782 + }
19783 +
19784 + function buildMeshNodeSection(definitions, permissions, type) {
19785 + var x = '<p style="color:#666;">Select a ' + type + ' to configure permissions:</p>';
19786 + x += '<select style="width:100%; padding:8px; margin-bottom:15px;" id="perm_' + type + 'Select" onchange="renderMeshNodePerms(\'' + type + '\', this.value)">';
19787 + x += '<option value="">-- Select ' + (type === 'mesh' ? 'Device Group' : 'Device') + ' --</option>';
19788 +
19789 + if (type === 'mesh') {
19790 + var list = permissionListData.meshes;
19791 + if (list) {
19792 + list.forEach(function(item) {
19793 + x += '<option value="' + EscapeHtml(item._id) + '">' + EscapeHtml(item.name) + '</option>';
19794 + });
19795 + }
19796 + } else {
19797 + // Group nodes by mesh
19798 + var groupedNodes = {};
19799 + if (permissionListData.nodes) {
19800 + permissionListData.nodes.forEach(function(n) {
19801 + var meshGroup = n.meshname || 'Ungrouped';
19802 + if (!groupedNodes[meshGroup]) groupedNodes[meshGroup] = [];
19803 + groupedNodes[meshGroup].push(n);
19804 + });
19805 + }
19806 + for (var meshName in groupedNodes) {
19807 + x += '<optgroup label="🖥️ ' + EscapeHtml(meshName) + '">';
19808 + groupedNodes[meshName].forEach(function(item) {
19809 + x += '<option value="' + EscapeHtml(item._id) + '"> ' + EscapeHtml(item.name) + '</option>';
19810 + });
19811 + x += '</optgroup>';
19812 + }
19813 + }
19814 + x += '</select>';
19815 +
19816 + x += '<div id="meshNodePermContent_' + type + '"></div>';
19817 +
19818 + return x;
19819 + }
19820 +
19821 + function renderMeshNodePerms(type, selectedId) {
19822 + var content = document.getElementById('meshNodePermContent_' + type);
19823 + if (!selectedId) {
19824 + content.innerHTML = '';
19825 + return;
19826 + }
19827 +
19828 + var data = currentPluginPermissions.data;
19829 + var definitions = data.definitions || {};
19830 + var permissions = data.permissions || {};
19831 + var defaults = data.defaults || {};
19832 +
19833 + var overrideKey = type === 'mesh' ? 'meshOverrides' : 'nodeOverrides';
19834 +
19835 + // Extract override data for the selected mesh/node
19836 + var overrideData = {};
19837 + for (var pk in definitions) {
19838 + if (permissions[pk] && permissions[pk][overrideKey] && permissions[pk][overrideKey][selectedId]) {
19839 + overrideData[pk] = permissions[pk][overrideKey][selectedId];
19840 + } else {
19841 + overrideData[pk] = {
19842 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
19843 + denied: { users: [], userGroups: [], meshes: [], nodes: [] }
19844 + };
19845 + }
19846 + }
19847 +
19848 + var displayName = '';
19849 + var list = type === 'mesh' ? permissionListData.meshes : permissionListData.nodes;
19850 + if (list) {
19851 + var item = list.find(function(i) { return i._id === selectedId; });
19852 + if (item) displayName = item.name;
19853 + }
19854 +
19855 + // Store current override data for this mesh/node in a temporary location
19856 + window.currentPermOverride = { type: type, id: selectedId, data: overrideData };
19857 +
19858 + var x = '<div style="background:#d1ecf1; padding:10px; border-radius:4px; margin-bottom:15px;"><strong>' + EscapeHtml(displayName) + '</strong> - Specific permissions</div>';
19859 + x += buildPermissionSection(definitions, permissions, defaults, type, selectedId, overrideData);
19860 +
19861 + content.innerHTML = x;
19862 + }
19863 +
19864 + function savePluginPermissionsEx() {
19865 + if (!currentPluginPermissions || !permissionListData) return;
19866 +
19867 + var pluginName = document.getElementById('permPluginShortName').value;
19868 + var definitions = currentPluginPermissions.data.definitions || {};
19869 + var permissions = {};
19870 + var defaults = {};
19871 +
19872 + // Initialize mesh and node overrides storage if not exists
19873 + if (!window.meshOverridesState) window.meshOverridesState = {};
19874 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
19875 +
19876 + // Collect defaults from DOM
19877 + for (var permKey in definitions) {
19878 + defaults[permKey] = document.getElementById('perm_default_' + permKey).value;
19879 + }
19880 +
19881 + // Use permissionState for permissions (more reliable than DOM scraping)
19882 + for (var permKey in definitions) {
19883 + var state = permissionState[permKey] || { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
19884 + permissions[permKey] = {
19885 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
19886 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
19887 + meshOverrides: window.meshOverridesState[permKey] || {},
19888 + nodeOverrides: window.nodeOverridesState[permKey] || {}
19889 + };
19890 +
19891 + // Copy from permissionState (global)
19892 + if (state.allowed) {
19893 + permissions[permKey].allowed.users = state.allowed.users ? state.allowed.users.slice() : [];
19894 + permissions[permKey].allowed.userGroups = state.allowed.userGroups ? state.allowed.userGroups.slice() : [];
19895 + permissions[permKey].allowed.meshes = state.allowed.meshes ? state.allowed.meshes.slice() : [];
19896 + permissions[permKey].allowed.nodes = state.allowed.nodes ? state.allowed.nodes.slice() : [];
19897 + }
19898 + if (state.denied) {
19899 + permissions[permKey].denied.users = state.denied.users ? state.denied.users.slice() : [];
19900 + permissions[permKey].denied.userGroups = state.denied.userGroups ? state.denied.userGroups.slice() : [];
19901 + permissions[permKey].denied.meshes = state.denied.meshes ? state.denied.meshes.slice() : [];
19902 + permissions[permKey].denied.nodes = state.denied.nodes ? state.denied.nodes.slice() : [];
19903 + }
19904 + }
19905 +
19906 + meshserver.send({
19907 + action: 'setpluginpermissions',
19908 + plugin: pluginName,
19909 + data: { permissions: permissions, defaults: defaults }
19910 + });
19911 +
19912 + closePluginPermModal();
19913 + }
19914 +
19915 function goPlugin(pname, title) {
19916 if (pluginHandler == null) return;
19254 - if (pname == null) { Q('p43iframe').src = ''; } else { QH('p43title', title); Q('p43iframe').src = '/pluginadmin.ashx?pin=' + pname; go(43); }
19917 + if (pname == null) { document.getElementById('p43iframe').src = ''; } else { document.getElementById('p43title').innerHTML = title; document.getElementById('p43iframe').src = '/pluginadmin.ashx?pin=' + pname; go(43); }
19918 }
19919
19920 //
views/default3.handlebars
+737 -1
@@ -4576,6 +4576,22 @@
4576 pluginHandler.refreshPluginHandler();
4577 break;
4578 }
4579 + case 'pluginPermissions': {
4580 + handlePluginPermissions(message.event);
4581 + break;
4582 + }
4583 + case 'pluginPermissionsSet': {
4584 + if (message.event.success) {
4585 + alert("Permissions saved successfully");
4586 + } else {
4587 + alert("Error saving permissions: " + message.event.error);
4588 + }
4589 + break;
4590 + }
4591 + case 'pluginPermissionList': {
4592 + handlePluginPermissionList(message.event);
4593 + break;
4594 + }
4595 case 'plugin': {
4596 if (pluginHandler == null) break;
4597 try { pluginHandler[message.event.plugin][message.event.pluginaction](message); } catch (e) { console.log("PluginHandler could not event message: ", e); }
@@ -20857,6 +20873,8 @@
20873 1: {
20874 'disable': 'Disable',
20875 'upgrade': 'Upgrade',
20876 + 'reload': 'Reload',
20877 + 'permissions': 'Permissions',
20878 // 'downgrade': 'Downgrade' // disabling until plugins have prior versions available for better testing
20879 }
20880 };
@@ -20948,12 +20966,31 @@
20966 case 'disable':
20967 meshserver.send({ 'action': 'disableplugin', 'id': id });
20968 break;
20969 + case 'reload':
20970 + var plugin = null;
20971 + for (var i in installedPluginList) { if (installedPluginList[i]._id == id) { plugin = installedPluginList[i]; } }
20972 + if (plugin) {
20973 + meshserver.send({ 'action': 'reloadplugin', 'plugin': plugin.shortName });
20974 + }
20975 + break;
20976 }
20952 - QV('pluginRestartNotice', true);
20977 + QV('pluginRestartNotice', false);
20978 }
20979
20980 function pluginAction(elem, id) {
20981 if (pluginHandler == null) return;
20982 +
20983 + // Special handling for permissions - opens matrix dialog
20984 + if (elem.value == 'permissions') {
20985 + var plugin = null;
20986 + for (var i in installedPluginList) { if (installedPluginList[i]._id == id) { plugin = installedPluginList[i]; } }
20987 + if (plugin) {
20988 + openPluginPermissionsDialog(plugin);
20989 + }
20990 + elem.value = '';
20991 + return;
20992 + }
20993 +
20994 if (elem.value == 'downgrade') {
20995 meshserver.send({ 'action': 'getpluginversions', 'id': id });
20996 } else {
@@ -20970,6 +21007,705 @@
21007 if (pname == null) { Q('p43iframe').src = ''; } else { QH('p43title', title); Q('p43iframe').src = '/pluginadmin.ashx?pin=' + pname; go(43); }
21008 }
21009
21010 + // Plugin Permissions Dialog - Custom Modal Implementation
21011 + var currentPluginPermissions = null;
21012 + var permissionListData = null;
21013 + var permissionState = {}; // JavaScript state for permissions
21014 +
21015 + function openPluginPermissionsDialog(plugin) {
21016 + meshserver.send({ action: 'getpluginpermissions', plugin: plugin.shortName });
21017 + meshserver.send({ action: 'getpluginpermissionlist' });
21018 +
21019 + currentPluginPermissions = { plugin: plugin.shortName, name: plugin.name };
21020 +
21021 + // Create custom modal
21022 + var modalHtml = createPluginPermissionsModal(plugin);
21023 + document.body.insertAdjacentHTML('beforeend', modalHtml);
21024 +
21025 + // Show the custom modal
21026 + var modal = Q('pluginPermModal');
21027 + modal.style.display = 'block';
21028 + document.body.classList.add('modal-open');
21029 + }
21030 +
21031 + function createPluginPermissionsModal(plugin) {
21032 + var x = '<div id="pluginPermModal" class="modal" style="display:none; position:fixed; z-index:10000; left:0; top:0; width:100%; height:100%; background-color:rgba(0,0,0,0.5);">';
21033 + x += '<div class="modal-dialog modal-xl" style="margin:30px auto; max-width:900px;">';
21034 + x += '<div class="modal-content" style="background:#fff; border-radius:8px;">';
21035 +
21036 + // Header
21037 + x += '<div class="modal-header" style="padding:15px 20px; border-bottom:1px solid #dee2e6; display:flex; justify-content:space-between; align-items:center;">';
21038 + x += '<h5 class="modal-title" style="margin:0; font-size:18px;">Plugin Permissions - ' + EscapeHtml(plugin.name) + '</h5>';
21039 + x += '<button type="button" class="btn-close" onclick="closePluginPermModal()" aria-label="Close"></button>';
21040 + x += '</div>';
21041 +
21042 + // Body
21043 + x += '<div class="modal-body" id="pluginPermBody" style="padding:20px; max-height:70vh; overflow-y:auto;">';
21044 + x += '<div style="text-align:center; padding:40px;"><i class="fa-solid fa-spinner fa-spin"></i> Loading permissions...</div>';
21045 + x += '</div>';
21046 +
21047 + // Footer
21048 + x += '<div class="modal-footer" style="padding:15px 20px; border-top:1px solid #dee2e6; display:flex; justify-content:space-between; align-items:center;">';
21049 + x += '<div style="font-size:12px; color:#666;">Permissions cascade: Node → Mesh → Global → Default</div>';
21050 + x += '<div>';
21051 + x += '<button type="button" class="btn btn-secondary" style="margin-right:10px;" onclick="closePluginPermModal()">Cancel</button>';
21052 + x += '<button type="button" class="btn btn-primary" onclick="savePluginPermissionsEx()">Save</button>';
21053 + x += '</div>';
21054 + x += '</div>';
21055 +
21056 + x += '</div></div></div>';
21057 +
21058 + return x;
21059 + }
21060 +
21061 + function closePluginPermModal() {
21062 + var modal = Q('pluginPermModal');
21063 + if (modal) {
21064 + modal.remove();
21065 + document.body.classList.remove('modal-open');
21066 + }
21067 + }
21068 +
21069 + function handlePluginPermissions(msg) {
21070 + if (msg.action != 'pluginPermissions') return;
21071 + currentPluginPermissions.data = msg.permissions;
21072 + renderPermissionMatrixEx();
21073 + }
21074 +
21075 + function handlePluginPermissionList(msg) {
21076 + if (msg.action != 'pluginPermissionList') return;
21077 + permissionListData = msg.list;
21078 + renderPermissionMatrixEx();
21079 + }
21080 +
21081 + function renderPermissionMatrixEx() {
21082 + if (!currentPluginPermissions || !currentPluginPermissions.data || !permissionListData) return;
21083 +
21084 + var data = currentPluginPermissions.data;
21085 + var definitions = data.definitions || {};
21086 + var permissions = data.permissions || {};
21087 + var defaults = data.defaults || {};
21088 +
21089 + // Check if plugin has no permissions defined
21090 + if (Object.keys(definitions).length === 0) {
21091 + var body = document.getElementById('pluginPermBody');
21092 + if (body) {
21093 + body.innerHTML = '<div class="alert alert-info">This plugin has not defined any permissions.</div>';
21094 + }
21095 + return;
21096 + }
21097 +
21098 + // Initialize permissionState from loaded data
21099 + permissionState = {};
21100 + window.meshOverridesState = {};
21101 + window.nodeOverridesState = {};
21102 + for (var permKey in definitions) {
21103 + var permData = permissions[permKey] || {
21104 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
21105 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
21106 + meshOverrides: {},
21107 + nodeOverrides: {}
21108 + };
21109 + permissionState[permKey] = {
21110 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
21111 + denied: { users: [], userGroups: [], meshes: [], nodes: [] }
21112 + };
21113 + if (permData.allowed) {
21114 + if (permData.allowed.users) permissionState[permKey].allowed.users = permData.allowed.users.slice();
21115 + if (permData.allowed.userGroups) permissionState[permKey].allowed.userGroups = permData.allowed.userGroups.slice();
21116 + if (permData.allowed.meshes) permissionState[permKey].allowed.meshes = permData.allowed.meshes.slice();
21117 + if (permData.allowed.nodes) permissionState[permKey].allowed.nodes = permData.allowed.nodes.slice();
21118 + }
21119 + if (permData.denied) {
21120 + if (permData.denied.users) permissionState[permKey].denied.users = permData.denied.users.slice();
21121 + if (permData.denied.userGroups) permissionState[permKey].denied.userGroups = permData.denied.userGroups.slice();
21122 + if (permData.denied.meshes) permissionState[permKey].denied.meshes = permData.denied.meshes.slice();
21123 + if (permData.denied.nodes) permissionState[permKey].denied.nodes = permData.denied.nodes.slice();
21124 + }
21125 +
21126 + // Initialize meshOverridesState and nodeOverridesState from loaded data
21127 + window.meshOverridesState[permKey] = permData.meshOverrides || {};
21128 + window.nodeOverridesState[permKey] = permData.nodeOverrides || {};
21129 + }
21130 +
21131 + var x = '';
21132 +
21133 + // Custom tab buttons (no Bootstrap JS required)
21134 + x += '<div class="mb-3" style="border-bottom:1px solid #dee2e6;">';
21135 + x += '<button class="btn btn-sm btn-outline-primary me-1 active" id="btnTabGlobal" onclick="switchPermTab(\'global\')">Global</button>';
21136 + x += '<button class="btn btn-sm btn-outline-secondary me-1" id="btnTabMeshes" onclick="switchPermTab(\'meshes\')">Meshes</button>';
21137 + x += '<button class="btn btn-sm btn-outline-secondary" id="btnTabNodes" onclick="switchPermTab(\'nodes\')">Nodes</button>';
21138 + x += '</div>';
21139 +
21140 + // Global Panel
21141 + x += '<div id="panel-global">';
21142 + x += buildPermissionSection(definitions, permissions, defaults, 'global', null);
21143 + x += '</div>';
21144 +
21145 + // Meshes Panel (hidden by default)
21146 + x += '<div id="panel-meshes" style="display:none;">';
21147 + x += buildMeshNodeSection(definitions, permissions, 'mesh');
21148 + x += '</div>';
21149 +
21150 + // Nodes Panel (hidden by default)
21151 + x += '<div id="panel-nodes" style="display:none;">';
21152 + x += buildMeshNodeSection(definitions, permissions, 'node');
21153 + x += '</div>';
21154 +
21155 + x += '<input type="hidden" id="permPluginShortName" value="' + currentPluginPermissions.plugin + '" />';
21156 +
21157 + Q('pluginPermBody').innerHTML = x;
21158 + }
21159 +
21160 + function switchPermTab(tab) {
21161 + // Hide all panels
21162 + Q('panel-global').style.display = 'none';
21163 + Q('panel-meshes').style.display = 'none';
21164 + Q('panel-nodes').style.display = 'none';
21165 +
21166 + // Reset all buttons
21167 + Q('btnTabGlobal').className = 'btn btn-sm btn-outline-secondary me-1';
21168 + Q('btnTabMeshes').className = 'btn btn-sm btn-outline-secondary me-1';
21169 + Q('btnTabNodes').className = 'btn btn-sm btn-outline-secondary';
21170 +
21171 + // Show selected panel and highlight button
21172 + Q('panel-' + tab).style.display = 'block';
21173 + Q('btnTab' + tab.charAt(0).toUpperCase() + tab.slice(1)).className = 'btn btn-sm btn-primary me-1';
21174 + }
21175 +
21176 + function buildPermissionSection(definitions, permissions, defaults, level, parentId, overrideData) {
21177 + var x = '<div id="permAccordion">';
21178 +
21179 + var idx = 0;
21180 + for (var permKey in definitions) {
21181 + var def = definitions[permKey];
21182 + var permData = permissions[permKey] || {
21183 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
21184 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
21185 + meshOverrides: {},
21186 + nodeOverrides: {}
21187 + };
21188 + var defaultVal = defaults[permKey] || 'inherited';
21189 +
21190 + var collapseId = 'permCollapse-' + level + '-' + permKey + (parentId ? '-' + parentId : '');
21191 + var isFirst = idx === 0;
21192 +
21193 + // Determine which data to use: overrideData (for mesh/node) or permissionState (for global)
21194 + var useData;
21195 + if (overrideData && overrideData[permKey]) {
21196 + useData = overrideData[permKey];
21197 + } else if (permissionState[permKey]) {
21198 + useData = permissionState[permKey];
21199 + } else {
21200 + useData = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
21201 + }
21202 +
21203 + x += '<div class="mb-2" style="border:1px solid #dee2e6; border-radius:8px; overflow:hidden;">';
21204 + x += '<div style="padding:12px 15px; background:#f8f9fa; display:flex; justify-content:space-between; align-items:center; cursor:pointer;" onclick="togglePermCollapse(\'' + collapseId + '\')">';
21205 + x += '<div><strong>' + EscapeHtml(def.title || permKey) + '</strong><br><small class="text-muted">' + EscapeHtml(def.desc || '') + '</small></div>';
21206 + x += '<div style="min-width:120px; text-align:right;"><select class="form-select form-select-sm" style="width:auto; display:inline-block;" id="perm_default_' + permKey + '" onclick="event.stopPropagation()">';
21207 + x += '<option value="allowed"' + (defaultVal === 'allowed' ? ' selected' : '') + '>✓ Allowed</option>';
21208 + x += '<option value="denied"' + (defaultVal === 'denied' ? ' selected' : '') + '>✗ Denied</option>';
21209 + x += '<option value="inherited"' + (defaultVal === 'inherited' ? ' selected' : '') + '>↩ Inherited</option>';
21210 + x += '</select></div>';
21211 + x += '</div>';
21212 + x += '<div id="' + collapseId + '" class="perm-collapse" style="padding:15px;' + (isFirst ? '' : 'display:none;') + '">';
21213 +
21214 + // Allowed section
21215 + x += '<div class="row mb-3">';
21216 + x += '<div class="col-md-6">';
21217 + x += '<h6><span class="badge bg-success">Allowed</span></h6>';
21218 + x += buildAutocompleteSection(permKey, level, 'allowed', parentId, useData ? useData.allowed : { users: [], userGroups: [], meshes: [], nodes: [] });
21219 + x += '</div>';
21220 + x += '<div class="col-md-6">';
21221 + x += '<h6><span class="badge bg-danger">Denied</span></h6>';
21222 + x += buildAutocompleteSection(permKey, level, 'denied', parentId, useData ? useData.denied : { users: [], userGroups: [], meshes: [], nodes: [] });
21223 + x += '</div>';
21224 + x += '</div>';
21225 +
21226 + x += '</div></div>';
21227 + idx++;
21228 + }
21229 + x += '</div>';
21230 +
21231 + return x;
21232 + }
21233 +
21234 + function togglePermCollapse(id) {
21235 + var el = Q(id);
21236 + if (el.style.display === 'none') {
21237 + el.style.display = 'block';
21238 + } else {
21239 + el.style.display = 'none';
21240 + }
21241 + }
21242 +
21243 + function buildAutocompleteSection(permKey, level, accessType, parentId, currentData) {
21244 + var inputId = 'perm_input_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21245 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21246 + var containerId = 'perm_tags_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21247 +
21248 + var x = '<div class="position-relative">';
21249 + x += '<input type="text" class="form-control form-control-sm" id="' + inputId + '" placeholder="Search users, groups, devices..." ';
21250 + x += 'onkeyup="searchPermEntities(this, \'' + permKey + '\', \'' + level + '\', \'' + accessType + '\', \'' + (parentId || '') + '\')" ';
21251 + x += 'onfocus="showPermDropdown(\'' + listId + '\')" autocomplete="off">';
21252 + x += '<div id="' + listId + '" class="list-group w-100" style="max-height:200px; overflow-y:auto; display:none; cursor:pointer;"></div>';
21253 + x += '</div>';
21254 +
21255 + // Show selected tags
21256 + x += '<div id="' + containerId + '" class="mt-2" style="display:flex; flex-wrap:wrap; gap:5px;">';
21257 +
21258 + // Render existing selections
21259 + var allEntities = [];
21260 + if (permissionListData.users) allEntities = allEntities.concat(permissionListData.users);
21261 + if (permissionListData.userGroups) allEntities = allEntities.concat(permissionListData.userGroups);
21262 + if (permissionListData.meshes) allEntities = allEntities.concat(permissionListData.meshes);
21263 + if (permissionListData.nodes) allEntities = allEntities.concat(permissionListData.nodes);
21264 +
21265 + var selectedIds = currentData.users || [];
21266 + selectedIds.forEach(function(id) {
21267 + var entity = allEntities.find(function(e) { return e._id === id; });
21268 + if (entity) {
21269 + x += buildPermTag(entity, permKey, level, accessType, 'user', parentId);
21270 + }
21271 + });
21272 +
21273 + var selectedGroups = currentData.userGroups || [];
21274 + selectedGroups.forEach(function(id) {
21275 + var entity = allEntities.find(function(e) { return e._id === id; });
21276 + if (entity) {
21277 + x += buildPermTag(entity, permKey, level, accessType, 'userGroup', parentId);
21278 + }
21279 + });
21280 +
21281 + x += '</div>';
21282 +
21283 + return x;
21284 + }
21285 +
21286 + function buildPermTag(entity, permKey, level, accessType, entityType, parentId) {
21287 + var icon = entity._id.startsWith('user/') ? '👤' : (entity._id.startsWith('ugrp/') ? '👥' : (entity._id.startsWith('mesh/') ? '🖥️' : '💻'));
21288 + var tagId = 'perm_tag_' + entity._id.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
21289 + var safeId = entity._id.replace(/'/g, '___');
21290 +
21291 + return '<span class="badge" style="background:#6c757d; padding:5px 8px; font-size:12px;" id="' + tagId + '" data-actualid="' + EscapeHtml(entity._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + entityType + '" data-parentid="' + (parentId || '') + '">' + icon + ' ' + EscapeHtml(entity.name || entity._id) + ' <i class="fa-solid fa-times" style="cursor:pointer; margin-left:5px;" onclick="removePermTagFromTag(this)"></i></span>';
21292 + }
21293 +
21294 + function removePermTagFromTag(el) {
21295 + var span = el.parentElement;
21296 + removePermTag(span.getAttribute('data-actualid'), span.getAttribute('data-perm'), span.getAttribute('data-level'), span.getAttribute('data-access'), span.getAttribute('data-type'), span.getAttribute('data-parentid') || '');
21297 + }
21298 +
21299 + function searchPermEntities(input, permKey, level, accessType, parentId) {
21300 + var query = input.value.toLowerCase();
21301 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21302 + var list = Q(listId);
21303 + if (!list) return;
21304 +
21305 + if (query.length < 1) {
21306 + list.style.display = 'none';
21307 + return;
21308 + }
21309 +
21310 + // Get currently selected IDs to filter out
21311 + var selectedIds = [];
21312 + if (permissionState[permKey] && permissionState[permKey][accessType]) {
21313 + var state = permissionState[permKey][accessType];
21314 + if (state.users) selectedIds = selectedIds.concat(state.users);
21315 + if (state.userGroups) selectedIds = selectedIds.concat(state.userGroups);
21316 + if (state.meshes) selectedIds = selectedIds.concat(state.meshes);
21317 + if (state.nodes) selectedIds = selectedIds.concat(state.nodes);
21318 + }
21319 +
21320 + // Also check mesh/node overrides for the current context
21321 + if (level === 'mesh' && parentId && window.meshOverridesState && window.meshOverridesState[permKey] && window.meshOverridesState[permKey][parentId] && window.meshOverridesState[permKey][parentId][accessType]) {
21322 + var meshState = window.meshOverridesState[permKey][parentId][accessType];
21323 + if (meshState.users) selectedIds = selectedIds.concat(meshState.users);
21324 + if (meshState.userGroups) selectedIds = selectedIds.concat(meshState.userGroups);
21325 + if (meshState.meshes) selectedIds = selectedIds.concat(meshState.meshes);
21326 + if (meshState.nodes) selectedIds = selectedIds.concat(meshState.nodes);
21327 + }
21328 + if (level === 'node' && parentId && window.nodeOverridesState && window.nodeOverridesState[permKey] && window.nodeOverridesState[permKey][parentId] && window.nodeOverridesState[permKey][parentId][accessType]) {
21329 + var nodeState = window.nodeOverridesState[permKey][parentId][accessType];
21330 + if (nodeState.users) selectedIds = selectedIds.concat(nodeState.users);
21331 + if (nodeState.userGroups) selectedIds = selectedIds.concat(nodeState.userGroups);
21332 + if (nodeState.meshes) selectedIds = selectedIds.concat(nodeState.meshes);
21333 + if (nodeState.nodes) selectedIds = selectedIds.concat(nodeState.nodes);
21334 + }
21335 +
21336 + var results = [];
21337 + var groupedNodes = {};
21338 +
21339 + // Search users
21340 + if (permissionListData.users) {
21341 + permissionListData.users.forEach(function(u) {
21342 + if (selectedIds.indexOf(u._id) >= 0) return;
21343 + if ((u.name && u.name.toLowerCase().includes(query)) || (u.email && u.email.toLowerCase().includes(query))) {
21344 + results.push({ _id: u._id, name: u.name || u.email, type: 'user', icon: '👤' });
21345 + }
21346 + });
21347 + }
21348 +
21349 + // Search user groups
21350 + if (permissionListData.userGroups) {
21351 + permissionListData.userGroups.forEach(function(ug) {
21352 + if (selectedIds.indexOf(ug._id) >= 0) return;
21353 + if (ug.name && ug.name.toLowerCase().includes(query)) {
21354 + results.push({ _id: ug._id, name: ug.name, type: 'userGroup', icon: '👥' });
21355 + }
21356 + });
21357 + }
21358 +
21359 + // Search meshes (only for global level)
21360 + if (level === 'global' && permissionListData.meshes) {
21361 + permissionListData.meshes.forEach(function(m) {
21362 + if (selectedIds.indexOf(m._id) >= 0) return;
21363 + if (m.name && m.name.toLowerCase().includes(query)) {
21364 + results.push({ _id: m._id, name: m.name, type: 'mesh', icon: '🖥️' });
21365 + }
21366 + });
21367 + }
21368 +
21369 + // Search nodes and group by mesh
21370 + if (level === 'node' && permissionListData.nodes) {
21371 + permissionListData.nodes.forEach(function(n) {
21372 + if (selectedIds.indexOf(n._id) >= 0) return;
21373 + if (n.name && n.name.toLowerCase().includes(query)) {
21374 + var meshGroup = n.meshname || 'Ungrouped';
21375 + if (!groupedNodes[meshGroup]) groupedNodes[meshGroup] = [];
21376 + groupedNodes[meshGroup].push({ _id: n._id, name: n.name, type: 'node', icon: '💻', meshname: meshGroup });
21377 + }
21378 + });
21379 + }
21380 +
21381 + list.innerHTML = '';
21382 +
21383 + // Add individual results first
21384 + results.slice(0, 10).forEach(function(r) {
21385 + var safeId = r._id.replace(/'/g, '___');
21386 + list.innerHTML += '<a class="list-group-item list-group-item-action" style="padding:8px 12px; cursor:pointer;" data-id="' + safeId + '" data-actualid="' + EscapeHtml(r._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + r.type + '" data-name="' + EscapeHtml(r.name) + '" data-icon="' + r.icon + '" data-parentid="' + (parentId || '') + '" onclick="addPermEntityFromDropdown(this)">' + r.icon + ' ' + EscapeHtml(r.name) + ' <small class="text-muted">' + r.type + '</small></a>';
21387 + });
21388 +
21389 + // Add grouped nodes at the end
21390 + if (level === 'node') {
21391 + for (var meshName in groupedNodes) {
21392 + list.innerHTML += '<div class="list-group-item" style="padding:8px 12px; background:#eee; font-weight:bold; font-size:11px;">🖥️ ' + EscapeHtml(meshName) + '</div>';
21393 + groupedNodes[meshName].forEach(function(n) {
21394 + var safeId = n._id.replace(/'/g, '___');
21395 + list.innerHTML += '<a class="list-group-item list-group-item-action" style="padding:8px 12px; cursor:pointer; padding-left:20px;" data-id="' + safeId + '" data-actualid="' + EscapeHtml(n._id) + '" data-perm="' + permKey + '" data-level="' + level + '" data-access="' + accessType + '" data-type="' + n.type + '" data-name="' + EscapeHtml(n.name) + '" data-icon="' + n.icon + '" data-parentid="' + (parentId || '') + '" onclick="addPermEntityFromDropdown(this)">' + n.icon + ' ' + EscapeHtml(n.name) + '</a>';
21396 + });
21397 + }
21398 + }
21399 +
21400 + list.style.display = (results.length > 0 || (level === 'node' && Object.keys(groupedNodes).length > 0)) ? 'block' : 'none';
21401 + }
21402 +
21403 + function addPermEntityFromDropdown(el) {
21404 + var entityId = el.getAttribute('data-actualid');
21405 + var permKey = el.getAttribute('data-perm');
21406 + var level = el.getAttribute('data-level');
21407 + var accessType = el.getAttribute('data-access');
21408 + var entityType = el.getAttribute('data-type');
21409 + var entityName = el.getAttribute('data-name');
21410 + var entityIcon = el.getAttribute('data-icon');
21411 + var parentId = el.getAttribute('data-parentid') || '';
21412 + addPermEntity(entityId, permKey, level, accessType, entityType, parentId, entityName, entityIcon);
21413 + }
21414 +
21415 + function showPermDropdown(listId) {
21416 + var list = Q(listId);
21417 + if (list && list.children.length > 0) {
21418 + list.style.display = 'block';
21419 + }
21420 + }
21421 +
21422 + function addPermEntity(entityId, permKey, level, accessType, entityType, parentId, entityName, entityIcon) {
21423 + var containerId = 'perm_tags_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21424 + var container = Q(containerId);
21425 + var inputId = 'perm_input_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21426 + var listId = 'perm_list_' + level + '_' + accessType + '_' + permKey + (parentId ? '_' + parentId : '');
21427 +
21428 + // Clear input and hide dropdown
21429 + Q(inputId).value = '';
21430 + Q(listId).style.display = 'none';
21431 +
21432 + // Check if already exists
21433 + var existingTags = container.querySelectorAll('span');
21434 + for (var i = 0; i < existingTags.length; i++) {
21435 + if (existingTags[i].id && existingTags[i].id.includes(entityId.replace(/[^a-zA-Z0-9]/g, '_'))) {
21436 + return; // Already added
21437 + }
21438 + }
21439 +
21440 + // Add new tag
21441 + var tagId = 'perm_tag_' + entityId.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
21442 + var tagHtml = '<span class="badge" style="background:#6c757d; padding:5px 8px; font-size:12px;" id="' + tagId + '" data-actualid="' + EscapeHtml(entityId) + '">' + entityIcon + ' ' + EscapeHtml(entityName) + ' <i class="fa-solid fa-times" style="cursor:pointer; margin-left:5px;" onclick="removePermTag(\'' + entityId + '\', \'' + permKey + '\', \'' + level + '\', \'' + accessType + '\', \'' + entityType + '\', \'' + (parentId || '') + '\')"></i></span>';
21443 + container.insertAdjacentHTML('beforeend', tagHtml);
21444 +
21445 + // Initialize overrides storage
21446 + if (!window.meshOverridesState) window.meshOverridesState = {};
21447 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
21448 + if (!window.meshOverridesState[permKey]) window.meshOverridesState[permKey] = {};
21449 + if (!window.nodeOverridesState[permKey]) window.nodeOverridesState[permKey] = {};
21450 +
21451 + var entityTypeKey = entityType === 'user' ? 'users' : (entityType === 'userGroup' ? 'userGroups' : (entityType === 'mesh' ? 'meshes' : (entityType === 'node' ? 'nodes' : null)));
21452 +
21453 + // Skip if entityTypeKey is invalid
21454 + if (entityTypeKey === null) {
21455 + console.log('Invalid entityType:', entityType);
21456 + return;
21457 + }
21458 +
21459 + // Update permissionState only for global level
21460 + if (level === 'global') {
21461 + if (permissionState[permKey] && permissionState[permKey][accessType] && permissionState[permKey][accessType][entityTypeKey]) {
21462 + if (permissionState[permKey][accessType][entityTypeKey].indexOf(entityId) === -1) {
21463 + permissionState[permKey][accessType][entityTypeKey].push(entityId);
21464 + }
21465 + }
21466 + }
21467 +
21468 + // Update mesh/node overrides if in context
21469 + if (level === 'mesh' && parentId) {
21470 + if (!window.meshOverridesState[permKey][parentId]) {
21471 + window.meshOverridesState[permKey][parentId] = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
21472 + }
21473 + // Ensure accessType object exists
21474 + if (!window.meshOverridesState[permKey][parentId][accessType]) {
21475 + window.meshOverridesState[permKey][parentId][accessType] = { users: [], userGroups: [], meshes: [], nodes: [] };
21476 + }
21477 + if (window.meshOverridesState[permKey][parentId][accessType][entityTypeKey] && window.meshOverridesState[permKey][parentId][accessType][entityTypeKey].indexOf(entityId) === -1) {
21478 + window.meshOverridesState[permKey][parentId][accessType][entityTypeKey].push(entityId);
21479 + }
21480 + }
21481 + if (level === 'node' && parentId) {
21482 + if (!window.nodeOverridesState[permKey][parentId]) {
21483 + window.nodeOverridesState[permKey][parentId] = { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
21484 + }
21485 + // Ensure accessType object exists
21486 + if (!window.nodeOverridesState[permKey][parentId][accessType]) {
21487 + window.nodeOverridesState[permKey][parentId][accessType] = { users: [], userGroups: [], meshes: [], nodes: [] };
21488 + }
21489 + // Ensure entityTypeKey array exists
21490 + if (!window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey]) {
21491 + window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey] = [];
21492 + }
21493 + if (window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey] && window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey].indexOf(entityId) === -1) {
21494 + window.nodeOverridesState[permKey][parentId][accessType][entityTypeKey].push(entityId);
21495 + }
21496 + }
21497 + }
21498 +
21499 + function removePermTag(entityId, permKey, level, accessType, entityType, parentId) {
21500 + var tagId = 'perm_tag_' + entityId.replace(/[^a-zA-Z0-9]/g, '_') + '_' + permKey + '_' + level + '_' + accessType + (parentId ? '_' + parentId : '');
21501 + var tag = Q(tagId);
21502 + if (tag) tag.remove();
21503 +
21504 + // Initialize overrides storage
21505 + if (!window.meshOverridesState) window.meshOverridesState = {};
21506 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
21507 + if (!window.meshOverridesState[permKey]) window.meshOverridesState[permKey] = {};
21508 + if (!window.nodeOverridesState[permKey]) window.nodeOverridesState[permKey] = {};
21509 +
21510 + var entityTypeKey = entityType === 'user' ? 'users' : (entityType === 'userGroup' ? 'userGroups' : (entityType === 'mesh' ? 'meshes' : 'nodes'));
21511 +
21512 + // Remove from permissionState only for global level
21513 + if (level === 'global') {
21514 + if (permissionState[permKey] && permissionState[permKey][accessType] && permissionState[permKey][accessType][entityTypeKey]) {
21515 + var idx = permissionState[permKey][accessType][entityTypeKey].indexOf(entityId);
21516 + if (idx > -1) {
21517 + permissionState[permKey][accessType][entityTypeKey].splice(idx, 1);
21518 + }
21519 + }
21520 + }
21521 +
21522 + // Remove from mesh overrides if in mesh context
21523 + if (level === 'mesh' && parentId && window.meshOverridesState[permKey][parentId]) {
21524 + var override = window.meshOverridesState[permKey][parentId];
21525 + if (override[accessType] && override[accessType][entityTypeKey]) {
21526 + var idx3 = override[accessType][entityTypeKey].indexOf(entityId);
21527 + if (idx3 > -1) {
21528 + override[accessType][entityTypeKey].splice(idx3, 1);
21529 + }
21530 + }
21531 + }
21532 +
21533 + // Remove from node overrides if in node context
21534 + if (level === 'node' && parentId && window.nodeOverridesState[permKey][parentId]) {
21535 + var nodeOverride = window.nodeOverridesState[permKey][parentId];
21536 + if (nodeOverride[accessType] && nodeOverride[accessType][entityTypeKey]) {
21537 + var idx4 = nodeOverride[accessType][entityTypeKey].indexOf(entityId);
21538 + if (idx4 > -1) {
21539 + nodeOverride[accessType][entityTypeKey].splice(idx4, 1);
21540 + }
21541 + }
21542 + }
21543 + }
21544 + }
21545 +
21546 + // Update mesh overrides if in mesh context
21547 + if (level === 'mesh' && parentId && window.meshOverridesState[permKey][parentId]) {
21548 + var override = window.meshOverridesState[permKey][parentId];
21549 + if (override[accessType] && override[accessType][entityTypeKey]) {
21550 + var idx3 = override[accessType][entityTypeKey].indexOf(entityId);
21551 + if (idx3 > -1) {
21552 + override[accessType][entityTypeKey].splice(idx3, 1);
21553 + }
21554 + }
21555 + }
21556 +
21557 + // Update node overrides if in node context
21558 + if (level === 'node' && parentId && window.nodeOverridesState[permKey][parentId]) {
21559 + var nodeOverride = window.nodeOverridesState[permKey][parentId];
21560 + if (nodeOverride[accessType] && nodeOverride[accessType][entityTypeKey]) {
21561 + var idx4 = nodeOverride[accessType][entityTypeKey].indexOf(entityId);
21562 + if (idx4 > -1) {
21563 + nodeOverride[accessType][entityTypeKey].splice(idx4, 1);
21564 + }
21565 + }
21566 + }
21567 + }
21568 +
21569 + function buildMeshNodeSection(definitions, permissions, type) {
21570 + var x = '<p class="text-muted">Select a ' + type + ' to configure permissions:</p>';
21571 + x += '<select class="form-select mb-3" id="perm_' + type + 'Select" onchange="renderMeshNodePerms(\'' + type + '\', this.value)">';
21572 + x += '<option value="">-- Select ' + (type === 'mesh' ? 'Device Group' : 'Device') + ' --</option>';
21573 +
21574 + if (type === 'mesh') {
21575 + var list = permissionListData.meshes;
21576 + if (list) {
21577 + list.forEach(function(item) {
21578 + x += '<option value="' + EscapeHtml(item._id) + '">' + EscapeHtml(item.name) + '</option>';
21579 + });
21580 + }
21581 + } else {
21582 + // Group nodes by mesh
21583 + var groupedNodes = {};
21584 + if (permissionListData.nodes) {
21585 + permissionListData.nodes.forEach(function(n) {
21586 + var meshGroup = n.meshname || 'Ungrouped';
21587 + if (!groupedNodes[meshGroup]) groupedNodes[meshGroup] = [];
21588 + groupedNodes[meshGroup].push(n);
21589 + });
21590 + }
21591 + for (var meshName in groupedNodes) {
21592 + x += '<optgroup label="🖥️ ' + EscapeHtml(meshName) + '">';
21593 + groupedNodes[meshName].forEach(function(item) {
21594 + x += '<option value="' + EscapeHtml(item._id) + '"> ' + EscapeHtml(item.name) + '</option>';
21595 + });
21596 + x += '</optgroup>';
21597 + }
21598 + }
21599 + x += '</select>';
21600 +
21601 + x += '<div id="meshNodePermContent_' + type + '"></div>';
21602 +
21603 + return x;
21604 + }
21605 +
21606 + function renderMeshNodePerms(type, selectedId) {
21607 + var content = Q('meshNodePermContent_' + type);
21608 + if (!selectedId) {
21609 + content.innerHTML = '';
21610 + return;
21611 + }
21612 +
21613 + var data = currentPluginPermissions.data;
21614 + var definitions = data.definitions || {};
21615 + var permissions = data.permissions || {};
21616 + var defaults = data.defaults || {};
21617 +
21618 + var overrideKey = type === 'mesh' ? 'meshOverrides' : 'nodeOverrides';
21619 +
21620 + // Extract override data for the selected mesh/node
21621 + var overrideData = {};
21622 + for (var pk in definitions) {
21623 + if (permissions[pk] && permissions[pk][overrideKey] && permissions[pk][overrideKey][selectedId]) {
21624 + overrideData[pk] = permissions[pk][overrideKey][selectedId];
21625 + } else {
21626 + overrideData[pk] = {
21627 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
21628 + denied: { users: [], userGroups: [], meshes: [], nodes: [] }
21629 + };
21630 + }
21631 + }
21632 +
21633 + var displayName = '';
21634 + var list = type === 'mesh' ? permissionListData.meshes : permissionListData.nodes;
21635 + if (list) {
21636 + var item = list.find(function(i) { return i._id === selectedId; });
21637 + if (item) displayName = item.name;
21638 + }
21639 +
21640 + // Store current override data for this mesh/node in a temporary location
21641 + window.currentPermOverride = { type: type, id: selectedId, data: overrideData };
21642 +
21643 + var x = '<div class="alert alert-info"><strong>' + EscapeHtml(displayName) + '</strong> - Specific permissions for this ' + (type === 'mesh' ? 'device group' : 'device') + '</div>';
21644 + x += buildPermissionSection(definitions, permissions, defaults, type, selectedId, overrideData);
21645 +
21646 + content.innerHTML = x;
21647 + }
21648 +
21649 + function updatePermBadge(permKey) {
21650 + // Visual feedback when default changes
21651 + }
21652 +
21653 + function savePluginPermissionsEx() {
21654 + if (!currentPluginPermissions || !permissionListData) return;
21655 +
21656 + console.log('Saving permissionState:', JSON.stringify(permissionState));
21657 +
21658 + var pluginName = Q('permPluginShortName').value;
21659 + var definitions = currentPluginPermissions.data.definitions || {};
21660 + var permissions = {};
21661 + var defaults = {};
21662 +
21663 + // Initialize mesh and node overrides storage if not exists
21664 + if (!window.meshOverridesState) window.meshOverridesState = {};
21665 + if (!window.nodeOverridesState) window.nodeOverridesState = {};
21666 +
21667 + // Collect defaults from DOM
21668 + for (var permKey in definitions) {
21669 + defaults[permKey] = Q('perm_default_' + permKey).value;
21670 + }
21671 +
21672 + // Use permissionState for permissions (more reliable than DOM scraping)
21673 + for (var permKey in definitions) {
21674 + var state = permissionState[permKey] || { allowed: { users: [], userGroups: [], meshes: [], nodes: [] }, denied: { users: [], userGroups: [], meshes: [], nodes: [] } };
21675 + permissions[permKey] = {
21676 + allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
21677 + denied: { users: [], userGroups: [], meshes: [], nodes: [] },
21678 + meshOverrides: window.meshOverridesState[permKey] || {},
21679 + nodeOverrides: window.nodeOverridesState[permKey] || {}
21680 + };
21681 +
21682 + // Copy from permissionState (global)
21683 + if (state.allowed) {
21684 + permissions[permKey].allowed.users = state.allowed.users ? state.allowed.users.slice() : [];
21685 + permissions[permKey].allowed.userGroups = state.allowed.userGroups ? state.allowed.userGroups.slice() : [];
21686 + permissions[permKey].allowed.meshes = state.allowed.meshes ? state.allowed.meshes.slice() : [];
21687 + permissions[permKey].allowed.nodes = state.allowed.nodes ? state.allowed.nodes.slice() : [];
21688 + }
21689 + if (state.denied) {
21690 + permissions[permKey].denied.users = state.denied.users ? state.denied.users.slice() : [];
21691 + permissions[permKey].denied.userGroups = state.denied.userGroups ? state.denied.userGroups.slice() : [];
21692 + permissions[permKey].denied.meshes = state.denied.meshes ? state.denied.meshes.slice() : [];
21693 + permissions[permKey].denied.nodes = state.denied.nodes ? state.denied.nodes.slice() : [];
21694 + }
21695 + }
21696 +
21697 + console.log('Sending permissions:', JSON.stringify(permissions));
21698 +
21699 + meshserver.send({
21700 + action: 'setpluginpermissions',
21701 + plugin: pluginName,
21702 + data: { permissions: permissions, defaults: defaults }
21703 + });
21704 +
21705 + closePluginPermModal();
21706 + alert('Permissions saved successfully');
21707 + }
21708 +
21709 //
21710 // Access Control Functions
21711 // These must match server