librelad 8a3bf505c3 refactor(config): disperse Features section into category Advanced groups
The Features section was a grab-bag of ~27 toggles, most of which are
either category-specific (firewall, SSL, Docker network, SSH hardening)
or install-time choices that brick the box if flipped on a live
install (the WebUI / config / CLI / Docker requirements). One page
made auditing easier but flattened the risk hierarchy.

Reorganised so each toggle lives where it conceptually belongs, and
the dangerous install-time set is double-gated:

  network_docker     (Advanced)  DOCKER_NETWORK, DOCKER_NETWORK_PRUNE,
                                  DOCKER_SWITCHER
  network_firewall   (Advanced)  UFW, UFWD, WHITELIST_PORT_UPDATER  [new]
  network_domains    (field-Adv) SSLCERTS
  security_ssh       (Advanced)  SSHKEY_DOWNLOADER, SSH_DISABLE_PASSWORDS,
                                  BCRYPT_SAVE, GLUETUN_FOR_ALL          [new]
  general_terminal   (Advanced)  CRONTAB, CONFIGS_CHECK,
                                  CONFIGS_AUTO_UPDATE, CONFIGS_AUTO_DELETE,
                                  MISSING_IPS, CONTINUE_PROMPT,
                                  SUGGEST_INSTALLS, SUGGEST_METRICS
  general_install    (Adv+DEV)   CONFIG, COMMAND, WEBUI, WEBUI_SERVICE,
                                  DATABASE, PASSWORDS, DOCKER_CE,
                                  DOCKER_COMPOSE

The install-time eight are marked **ADVANCED** **DEV** — invisible
unless Developer Mode is on AND "Show Advanced Options" is expanded.
Each field's description was updated to note "Disabling on an existing
install will brick the system" / "install-time choice only" so a user
who does get to the toggle understands the gun before pulling the
trigger.

Other cleanup that fell out:
- Removed `configs/features/` directory entirely.
- Added the two new subcategories to SUBCATEGORY_ORDER in
  network/.category and security/.category.
- Dropped the `category === 'features'` Danger Zone header special-case
  in config-manager.js and its .danger-zone-section--header-only CSS
  variant (sole user).
- Trimmed an obsolete "Edit the features config" notice in
  check_requirements.sh.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 14:39:58 +01:00

381 lines
17 KiB
JavaScript
Executable File

// Config Manager - Main orchestrator for modular config system
if (typeof window.ConfigManager === 'undefined') {
//console.log('ConfigManager: Defining new ConfigManager class...');
class ConfigManager {
constructor() {
this.core = new ConfigCore();
this.domainManager = new DomainManager();
this.whitelistManager = new IPWhitelistManager();
this.renderer = new ConfigRenderer();
this.sidebar = new ConfigSidebar();
this.form = new ConfigForm();
this.utils = new ConfigUtils();
// Expose IPWhitelistManager globally for wrapper functions
window.IPWhitelistManager = this.whitelistManager;
}
async renderConfig(category) {
//console.log('ConfigManager: Rendering ' + category + ' config...');
const configSection = document.getElementById('config-section');
if (!configSection) {
console.error('ConfigManager: config-section element not found');
return;
}
// The sidebar and page headers read window.configData. Load it up front so
// populateSidebar() has categories on a cold admin visit (e.g. straight
// from the dashboard); otherwise the overview/tools branches below render
// an empty sidebar until something else populates configData. Cached after
// the first call, so the config-category path below is a cache hit.
try { await this.core.loadConfig(category); } catch (e) {}
// Tool controllers are loaded on demand — they're not in index.html's
// initial <script> block (Phase B of the WebUI lazy-load work). Falls
// back gracefully if window.spaClean isn't around for some reason
// (e.g. legacy bootstrap path).
const lazyLoad = (src) =>
window.spaClean?.loadScript ? window.spaClean.loadScript(src) : Promise.resolve();
// Overview is the Admin landing — an ops/health board, not a config form.
if (category === 'overview') {
try { this.sidebar.populateSidebar(); } catch (e) {}
// charts.js is the chart-rendering helper admin-overview pulls in.
await Promise.all([
lazyLoad('/js/components/admin/admin-overview.js'),
lazyLoad('/js/components/admin/charts.js')
]);
if (typeof AdminOverview !== 'undefined') {
window.adminOverview = new AdminOverview('config-section');
await window.adminOverview.init();
} else {
configSection.innerHTML = '<div class="error">Admin overview failed to load.</div>';
}
return;
}
// SSH Access is an admin tool page that lives in this sidebar rather than
// a config category — render its own controller into the main pane.
if (category === 'ssh-access') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await lazyLoad('/js/components/ssh/ssh-page.js');
if (typeof SshPage !== 'undefined') {
window.sshPage = new SshPage('config-section');
await window.sshPage.init();
} else {
configSection.innerHTML = '<div class="error">SSH Access page failed to load.</div>';
}
return;
}
// Peers is an admin tool page — named references to other LibrePortal
// instances (kind=backup-channel for shared-backup migrate, or
// kind=direct-ssh-direct for live SSH pulls). Same shape as SSH Access:
// we inject its content template, then init PeersPage.
if (category === 'peers') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await lazyLoad('/js/components/peers/peers-page.js');
try {
const html = await fetch('/html/peers-content.html').then(r => r.text());
configSection.innerHTML = html;
} catch (e) {
configSection.innerHTML = '<div class="error">Peers page template failed to load.</div>';
return;
}
if (typeof PeersPage !== 'undefined') {
window.peersPage = new PeersPage('config-section');
await window.peersPage.init();
} else {
configSection.innerHTML = '<div class="error">PeersPage controller not loaded.</div>';
}
return;
}
// System is an admin tool page (live host + per-app statistics) with its
// own controller, like SSH Access above.
if (category === 'system') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await lazyLoad('/js/components/admin/admin-system.js');
if (typeof AdminSystem !== 'undefined') {
window.adminSystem = new AdminSystem('config-section');
await window.adminSystem.init();
} else {
configSection.innerHTML = '<div class="error">System page failed to load.</div>';
}
return;
}
try {
// Show loading state with enhanced box styling
configSection.innerHTML = `
<div class="loading-content" style="
text-align: center;
padding: 60px 20px;
background: var(--input-bg);
border: 2px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
width: calc(100% - 40px);
height: calc(100% - 40px);
box-sizing: border-box;
margin: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 400px;
">
<div class="loading-spinner" style="
width: 24px;
height: 24px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px auto;
display: inline-block;
"></div>
<div class="loading-message" style="
font-size: 16px;
color: #ffffff;
font-weight: 500;
margin-bottom: 8px;
">
Loading configuration...
</div>
<div class="loading-subtitle" style="
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
font-style: italic;
">
${this.core.getRandomLoadingMessage()}
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
// Load configuration data
const configData = await this.core.loadConfig(category);
// Populate sidebar with categories
this.sidebar.populateSidebar();
if (Object.keys(configData).length === 0) {
configSection.innerHTML = '<div class="no-config"><h3>No Configuration Available</h3><p>No configuration items found for this category.</p></div>';
return;
}
// Render configuration sections
var formHTML = '';
var self = this; // Preserve 'this' context
//console.log('ConfigManager: About to process configData entries:', Object.keys(configData));
// Filter subcategories by type
const subcategoryTypes = this.utils.filterSubcategoriesByType(configData, category);
// Render regular subcategories
for (const subcategoryName of subcategoryTypes.regular) {
const subcategoryData = configData[subcategoryName];
//console.log('ConfigManager: Processing regular subcategory:', subcategoryName, 'data:', subcategoryData);
if (typeof subcategoryData === 'object' && subcategoryData !== null) {
//console.log('ConfigManager: Calling renderSubcategory for:', subcategoryName);
formHTML += await self.renderSubcategory.call(self, category, subcategoryName, subcategoryData);
}
}
// Render advanced and unused sections
formHTML = await this.utils.renderSectionedContent(formHTML, subcategoryTypes.advanced, subcategoryTypes.unused, self, category, configData);
//console.log('ConfigManager: Final formHTML length:', formHTML.length);
if (formHTML) {
// Page-level header for the config section. Mirrors the
// .backup-page-header used on /backup so /config gets the same
// prominent H1 + description above the form fields. Looked up from
// window.configData.categories[category] so titles/descriptions
// come straight from the .category metadata file.
var catMeta = (window.configData && window.configData.categories && window.configData.categories[category]) || {};
var catTitle = catMeta.title || (typeof ConfigShared !== 'undefined' && ConfigShared.formatCategoryName ? ConfigShared.formatCategoryName(category) : category);
var catDesc = catMeta.description || '';
var catIcon = catMeta.icon || category;
var headerHTML =
'<div class="page-header config-page-header">' +
'<img class="page-header-icon" src="/icons/config/' + catIcon + '.svg" alt="" onerror="this.style.display=\'none\'">' +
'<div class="page-header-title">' +
'<div class="admin-breadcrumb">Config</div>' +
'<h1>' + catTitle + '</h1>' +
(catDesc ? '<p>' + catDesc + '</p>' : '') +
'</div>' +
'</div>';
configSection.innerHTML = headerHTML + '<form id="config-form" class="config-form">' + formHTML + '<div class="config-divider"></div><div class="config-actions">' +
'<button type="submit" class="btn btn-primary">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>' +
'<polyline points="7 10 12 15 17 10"></polyline>' +
'<line x1="12" y1="15" x2="12" y2="3"></line>' +
'</svg>' +
'Save Configuration' +
'</button>' +
'<button type="button" class="btn btn-secondary" onclick="window.configManager.resetForm()">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="23 4 23 10 17 10"></polyline>' +
'<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>' +
'</svg>' +
'Reset' +
'</button>' +
'</div></form>';
// Wire the submit event so it dispatches the config-update task
// instead of letting the browser fall back to a GET that dumps every
// CFG value (including passwords) into the URL.
if (this.form && typeof this.form.attachSubmitHandler === 'function') {
this.form.attachSubmitHandler();
}
}
//console.log('ConfigManager: Successfully rendered ' + category + ' config');
// Force rediscover toggles to handle timing issues
if (window.toggleManager && window.toggleManager.forceRediscover) {
setTimeout(() => {
window.toggleManager.forceRediscover();
}, 200);
}
} catch (error) {
console.error('ConfigManager: Error rendering ' + category + ' config: ', error);
configSection.innerHTML = '<div class="error-container"><h3>Error Loading Configuration</h3><p>Failed to load configuration: ' + error.message + '</p><button type="button" class="btn btn-primary" onclick="window.configManager.renderConfig(\'' + category + '\')">Retry</button></div>';
}
}
async renderSubcategory(category, subcategoryName, subcategoryData) {
//console.log('ConfigManager: renderSubcategory() called - category: ' + category + ', subcategory: ' + subcategoryName);
var displaySubcategory = this.utils.formatSubcategoryName(subcategoryName);
// Strip the parent-category prefix from the display title so the user
// sees "Basic" instead of "General Basic" while on the General page.
if (typeof ConfigShared !== 'undefined' && ConfigShared.stripCategoryPrefix) {
displaySubcategory = ConfigShared.stripCategoryPrefix(displaySubcategory, category);
}
var subcategoryDescription = this.utils.cleanDescription(subcategoryData.description || '');
// The subcategoryData IS the config items, not a container for them
var configItems = [];
// Look for actual config items in the main config object
if (window.configData && window.configData.config) {
Object.entries(window.configData.config).forEach(function([configKey, configValue]) {
if (configValue.subcategory === subcategoryName) {
configItems.push({
key: configKey,
title: configValue.title,
description: configValue.description,
value: configValue.value,
options: configValue.options,
master: configValue.master,
subcategory: configValue.subcategory
});
}
});
}
//console.log('ConfigManager: Processing subcategory:', subcategoryName, 'data:', subcategoryData);
//console.log('ConfigManager: configItems count: ' + configItems.length);
//console.log('ConfigManager: All config items keys:', configItems.map(item => item.key));
if (configItems.length === 0) {
//console.log('ConfigManager: No config items, returning empty string');
return '';
}
//console.log('ConfigManager: renderSubcategory called with:', {
//category,
//subcategoryName,
//displaySubcategory,
//hasData: !!subcategoryData
//});
// Check for master toggle in this subcategory
var masterKey = configItems.find(function(item) { return item.master === true; });
//console.log('ConfigManager: masterKey found: ' + !!masterKey, masterKey ? masterKey.key : null);
// Look for any ENABLED options and use universal toggle renderer
var enabledKey = configItems.find(function(item) {
//console.log('Checking item for ENABLED:', item.key, item.key.includes('ENABLED'));
return item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE';
});
//console.log('ConfigManager: enabledKey found: ' + !!enabledKey, enabledKey ? enabledKey.key : null);
// Special handling for domains section
var isDomains = subcategoryName.includes('domains') || subcategoryName.includes('network_domains');
//console.log('ConfigManager: isDomains:', isDomains);
// Special handling for IP whitelist section
var isWhitelist = subcategoryName === 'network_whitelist' || subcategoryName.includes('whitelist');
//console.log('ConfigManager: subcategoryName:', subcategoryName, 'isWhitelist:', isWhitelist);
var resultHTML = '';
if (isDomains) {
//console.log('ConfigManager: Using domains renderer');
// Render domains section with special handling
resultHTML = await this.domainManager.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription);
} else if (isWhitelist) {
//console.log('ConfigManager: Using whitelist renderer');
resultHTML = await this.whitelistManager.renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription);
} else if (enabledKey) {
//console.log('ConfigManager: Using universal toggle renderer');
// Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE
resultHTML = window.toggleManager ? window.toggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription) : '';
} else if (masterKey) {
//console.log('ConfigManager: Using master toggle renderer');
// Render with master toggle
resultHTML = this.renderer.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription);
} else {
//console.log('ConfigManager: Using regular renderer');
// Render regular subcategory
resultHTML = this.renderer.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription);
}
//console.log('ConfigManager: resultHTML length:', resultHTML.length);
return resultHTML;
}
// Delegate form operations to ConfigForm
resetForm() {
return this.form.resetForm();
}
// Domain management methods
addDomain() {
return window.domainManager.addDomain();
}
deleteDomain(domainKey, buttonElement) {
return window.domainManager.deleteDomain(domainKey, buttonElement);
}
async saveConfig() {
return await this.form.saveConfig();
}
showNotification(message, type) {
return this.form.showNotification(message, type);
}
}
// Export to global scope
window.ConfigManager = ConfigManager;
} else {
//console.log('ConfigManager: Already exists, using existing instance');
}