Add Theme Pack Support for Custom UI Themes (Modern UI ONLY) (#7506)

* Add theme pack support for domain customization Introduces a 'themePack' option in the domain configuration schema, updates meshctrl.js to support setting this value, and enhances webserver.js to serve theme pack assets and inject theme-specific CSS/JS when enabled. This allows domains to override UI appearance using dedicated theme packs.

TheDevRyan committed Feb 1, 2026 at 17:11 UTC 0512dec89ffdafa5f915686c213e71d499d48800
3 files changed +54 -4
meshcentral-config-schema.json
+4
@@ -2005,6 +2005,10 @@
2005 "customUI": {
2006 "type": "object"
2007 },
2008 + "themePack": {
2009 + "type": "string",
2010 + "description": "The name of the theme pack to use for this domain. Example: \"Stylish-UI\""
2011 + },
2012 "customFiles": {
2013 "type": "object",
2014 "description": "Advanced customization system allowing multiple CSS and JavaScript file configurations per domain. Each configuration is identified by a unique name and can target specific templates using the 'scope' property, enabling granular control over where custom files are loaded. All custom files are loaded after the default custom.css and custom.js files.",
meshctrl.js
+3 -2
@@ -1190,14 +1190,15 @@ function displayConfigHelp() {
1190 console.log(" --removefromdomain [domain] - Remove values from the domain.");
1191 console.log("\r\nWith adddomain, removedomain, settodomain and removefromdomain you can add the key and value pair. For example:\r\n");
1192 console.log(" --adddomain \"MyDomain\" --title \"My Server Name\" --newAccounts false");
1193 + console.log(" --settodomain \"MyDomain\" --themePack \"Stylish-UI\"");
1194 console.log(" --settodomain \"MyDomain\" --title \"My Server Name\"");
1195 console.log(" --removefromdomain \"MyDomain\" --title");
1196 }
1197
1198 function performConfigOperations(args) {
1198 - var domainValues = ['title', 'title2', 'titlepicture', 'trustedcert', 'welcomepicture', 'welcometext', 'userquota', 'meshquota', 'newaccounts', 'usernameisemail', 'newaccountemaildomains', 'newaccountspass', 'newaccountsrights', 'geolocation', 'lockagentdownload', 'userconsentflags', 'Usersessionidletimeout', 'auth', 'ldapoptions', 'ldapusername', 'ldapuserbinarykey', 'ldapuseremail', 'footer', 'certurl', 'loginKey', 'userallowedip', 'agentallowedip', 'agentnoproxy', 'agentconfig', 'orphanagentuser', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording', 'hide', 'customFiles'];
1199 + var domainValues = ['title', 'title2', 'titlepicture', 'trustedcert', 'welcomepicture', 'welcometext', 'userquota', 'meshquota', 'newaccounts', 'usernameisemail', 'newaccountemaildomains', 'newaccountspass', 'newaccountsrights', 'geolocation', 'lockagentdownload', 'userconsentflags', 'Usersessionidletimeout', 'auth', 'ldapoptions', 'ldapusername', 'ldapuserbinarykey', 'ldapuseremail', 'footer', 'certurl', 'loginKey', 'userallowedip', 'agentallowedip', 'agentnoproxy', 'agentconfig', 'orphanagentuser', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording', 'hide', 'customFiles', 'themePack'];
1200 var domainObjectValues = ['ldapoptions', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording', 'customFiles'];
1200 - var domainArrayValues = ['newaccountemaildomains', 'newaccountsrights', 'loginkey', 'agentconfig'];
1201 + var domainArrayValues = ['newaccountemaildomains', 'newaccountsrights', 'loginkey', 'agentconfig', 'themePack'];
1202 var configChange = false;
1203 var fs = require('fs');
1204 var path = require('path');
webserver.js
+47 -2
@@ -7675,6 +7675,25 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7675 // Handle all incoming requests as web relays
7676 obj.webRelayRouter.head('/*', function (req, res) { try { handleWebRelayRequest(req, res); } catch (ex) { console.log(ex); } })
7677 }
7678 +
7679 + // Theme Pack Override Middleware
7680 + obj.app.use(url, function (req, res, next) {
7681 + if (req.method !== 'GET') return next();
7682 + var domain = getDomain(req);
7683 + // Serve theme pack files if domain has a theme pack configured
7684 + if (domain && domain.themepack) {
7685 + var themeFilePath = obj.path.join(obj.parent.datapath, 'theme-pack', domain.themepack, 'public', req.path);
7686 + // Prevent directory traversal
7687 + if (themeFilePath.indexOf('..') >= 0) return next();
7688 +
7689 + obj.fs.stat(themeFilePath, function (err, stats) {
7690 + if (err || !stats.isFile()) return next();
7691 + res.sendFile(themeFilePath);
7692 + });
7693 + } else {
7694 + next();
7695 + }
7696 + });
7697
7698 // Indicates to ExpressJS that the override public folder should be used to serve static files.
7699 obj.app.use(url, function(req, res, next){
@@ -9604,7 +9623,32 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
9623
9624 return jsTags.trim();
9625 }
9607 -
9626 +
9627 + function generateThemePackCSSTags(domain, currentTemplate) {
9628 + var cssTags = '';
9629 + // Load theme pack if domain has one configured AND (domain forces sitestyle 3 OR user is viewing sitestyle 3)
9630 + var isModernUI = (currentTemplate === 'default3') || (domain.sitestyle === 3);
9631 + if (domain && domain.themepack && isModernUI) {
9632 + var themePath = obj.path.join(obj.parent.datapath, 'theme-pack', domain.themepack, 'public');
9633 + if (obj.fs.existsSync(obj.path.join(themePath, 'styles', 'theme.css'))) {
9634 + cssTags += '<link keeplink=1 type="text/css" href="styles/theme.css" media="screen" rel="stylesheet" title="CSS" />\n ';
9635 + }
9636 + }
9637 + return cssTags;
9638 + }
9639 + function generateThemePackJSTags(domain, currentTemplate) {
9640 + var jsTags = '';
9641 + // Load theme pack if domain has one configured AND (domain forces sitestyle 3 OR user is viewing sitestyle 3)
9642 + var isModernUI = (currentTemplate === 'default3') || (domain.sitestyle === 3);
9643 + if (domain && domain.themepack && isModernUI) {
9644 + var themePath = obj.path.join(obj.parent.datapath, 'theme-pack', domain.themepack, 'public');
9645 + if (obj.fs.existsSync(obj.path.join(themePath, 'scripts', 'theme.js'))) {
9646 + jsTags += '<script keeplink=1 type="text/javascript" src="scripts/theme.js"></script>\n ';
9647 + }
9648 + }
9649 + return jsTags;
9650 + }
9651 +
9652 // Return the correct render page arguments.
9653 function getRenderArgs(xargs, req, domain, page) {
9654 var minify = (domain.minify == true);
@@ -9658,7 +9702,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
9702 xargs.customCSSTags = generateCustomCSSTags(null, page);
9703 xargs.customJSTags = generateCustomJSTags(null, page);
9704 }
9661 -
9705 + xargs.customCSSTags += generateThemePackCSSTags(domain, page);
9706 + xargs.customJSTags += generateThemePackJSTags(domain, page);
9707 return xargs;
9708 }
9709