A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
1646 lines
67 KiB
JavaScript
Executable File
1646 lines
67 KiB
JavaScript
Executable File
// Simple Config Manager - Direct approach without complex dependencies
|
||
class ConfigManager {
|
||
constructor() {
|
||
this.cache = new Map();
|
||
}
|
||
|
||
getRandomLoadingMessage() {
|
||
const messages = [
|
||
"Preparing your configuration settings...",
|
||
"Gathering the finest configuration options...",
|
||
"Tuning up your system preferences...",
|
||
"Organizing your configuration categories...",
|
||
"Loading the perfect settings for you...",
|
||
"Crafting your personalized configuration...",
|
||
"Aligning your configuration stars...",
|
||
"Brewing the ideal configuration blend...",
|
||
"Setting up your configuration masterpiece...",
|
||
"Polishing your configuration preferences...",
|
||
"Configuring things just right for you...",
|
||
"Preparing your digital control panel...",
|
||
"Gathering your system's best settings...",
|
||
"Optimizing your configuration experience...",
|
||
"Loading your configuration superpowers..."
|
||
];
|
||
|
||
return messages[Math.floor(Math.random() * messages.length)];
|
||
}
|
||
|
||
async loadConfig(category) {
|
||
//console.log(`ConfigManager: Loading ${category} config...`);
|
||
|
||
// Check cache first
|
||
if (this.cache.has('unified')) {
|
||
//console.log(`ConfigManager: Using cached unified config`);
|
||
const unifiedData = this.cache.get('unified');
|
||
return this.filterConfigByCategory(unifiedData, category);
|
||
}
|
||
|
||
try {
|
||
// Load unified config data
|
||
const response = await fetch('/data/config/generated/configs.json');
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load configs.json: ${response.status}`);
|
||
}
|
||
|
||
const configData = await response.json();
|
||
//console.log(`ConfigManager: Loaded unified config:`, configData);
|
||
|
||
// Cache the result
|
||
this.cache.set('unified', configData);
|
||
|
||
// Filter by requested category
|
||
const categoryData = this.filterConfigByCategory(configData, category);
|
||
//console.log(`ConfigManager: Filtered ${category} config:`, categoryData);
|
||
|
||
return categoryData;
|
||
|
||
} catch (error) {
|
||
console.error(`ConfigManager: Error loading ${category} config:`, error);
|
||
return { config: {}, description: 'Failed to load configuration' };
|
||
}
|
||
}
|
||
|
||
filterConfigByCategory(unifiedData, category) {
|
||
if (!unifiedData || !unifiedData.config) {
|
||
return { config: {}, categories: {} };
|
||
}
|
||
|
||
// Filter config items by category
|
||
const filteredConfig = {};
|
||
Object.entries(unifiedData.config).forEach(([key, value]) => {
|
||
if (value.category === category) {
|
||
filteredConfig[key] = value;
|
||
}
|
||
});
|
||
|
||
return {
|
||
config: filteredConfig,
|
||
categories: unifiedData.categories || {},
|
||
subcategories: unifiedData.subcategories || {},
|
||
configType: unifiedData.configType,
|
||
name: unifiedData.name
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Show loading with enhanced visual
|
||
configSection.innerHTML = `
|
||
<div class="loading-enhanced" style="padding: 22px;">
|
||
<div class="loading-content" style="
|
||
text-align: center;
|
||
padding: 22px;
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
">
|
||
<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: var(--text-primary, #fff);
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
">
|
||
Loading configuration...
|
||
</div>
|
||
<div class="loading-subtitle" style="
|
||
font-size: 14px;
|
||
color: var(--text-color, #fff);
|
||
font-style: italic;
|
||
">
|
||
${this.getRandomLoadingMessage()}
|
||
</div>
|
||
</div>
|
||
<style>
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
</style>
|
||
</div>
|
||
`;
|
||
|
||
// Update loading bar if available
|
||
if (typeof router !== 'undefined' && router.updateProgress) {
|
||
router.updateProgress(60);
|
||
}
|
||
|
||
try {
|
||
// Load config data
|
||
const configData = await this.loadConfig(category);
|
||
const config = configData.config || {};
|
||
|
||
// Update loading bar
|
||
if (typeof router !== 'undefined' && router.updateProgress) {
|
||
router.updateProgress(70);
|
||
}
|
||
|
||
if (Object.keys(config).length === 0) {
|
||
configSection.innerHTML = '<div class="error">No configuration available</div>';
|
||
return;
|
||
}
|
||
|
||
// Use the original ConfigShared system for beautiful rendering
|
||
if (typeof ConfigShared !== 'undefined') {
|
||
await this.renderWithOriginalStyling(category, configData);
|
||
} else {
|
||
// Fallback: load ConfigShared and then render
|
||
if (typeof router !== 'undefined' && router.updateProgress) {
|
||
router.updateProgress(75);
|
||
}
|
||
await this.loadScript('/js/components/config/config-options.js');
|
||
await this.loadScript('/js/components/config/config-shared.js');
|
||
await this.renderWithOriginalStyling(category, configData);
|
||
}
|
||
|
||
// Final progress update
|
||
if (typeof router !== 'undefined' && router.updateProgress) {
|
||
router.updateProgress(80);
|
||
}
|
||
|
||
//console.log(`ConfigManager: Successfully rendered ${category} config`);
|
||
|
||
// Initialize git field visibility after rendering
|
||
if (typeof initializeGitFieldVisibility === 'function') {
|
||
setTimeout(() => {
|
||
initializeGitFieldVisibility();
|
||
}, 100);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`ConfigManager: Error rendering ${category} config:`, error);
|
||
configSection.innerHTML = `<div class="error">Failed to load ${category} configuration: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async renderWithOriginalStyling(category, configData) {
|
||
//console.log(`renderWithOriginalStyling: category=${category}, CFG_INSTALL_MODE=${configData.config?.CFG_INSTALL_MODE?.value}`);
|
||
const configSection = document.getElementById('config-section');
|
||
const config = configData.config || {};
|
||
const subcategories = configData.subcategories || {};
|
||
|
||
// Check if we have subcategories data available
|
||
const hasSubcategories = Object.keys(subcategories).length > 0;
|
||
|
||
// Use shared categorization functionality
|
||
const categorized = ConfigShared.categorizeConfigs(config);
|
||
const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized;
|
||
|
||
// Render using the original system's approach with advanced/unused sections
|
||
let formHTML = `
|
||
<div class="config-title">
|
||
<h3>${this.formatCategoryName(category)} Configuration</h3>
|
||
<p>${configData.description || 'Configure settings for ' + category}</p>
|
||
</div>
|
||
<div class="config-container">
|
||
<form id="config-form-${category}" class="config-form">
|
||
`;
|
||
|
||
// Add requirements warning for requirements category
|
||
if (category === 'requirements') {
|
||
formHTML += ConfigShared.generateRequirementsWarning();
|
||
}
|
||
|
||
// Add danger zone warning for features category
|
||
if (category === 'features') {
|
||
formHTML += `
|
||
<div class="danger-zone-banner">
|
||
<div class="danger-zone-content">
|
||
<span class="danger-zone-icon">⚠️</span>
|
||
<div class="danger-zone-text">
|
||
<strong>Danger Zone</strong> - These options are for advanced users and may affect system stability
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Render using subcategories structure if available, otherwise fall back to original
|
||
if (hasSubcategories) {
|
||
// Render using new subcategories structure
|
||
const regularSubcategories = [];
|
||
const advancedSubcategories = [];
|
||
const unusedSubcategories = [];
|
||
|
||
// Filter subcategories by category and separate into regular, advanced, and unused
|
||
for (const [subcategoryName, subcategoryData] of Object.entries(subcategories)) {
|
||
if (subcategoryData.category === category) {
|
||
if (subcategoryData.description.includes('**ADVANCED**')) {
|
||
advancedSubcategories.push(subcategoryName);
|
||
} else if (subcategoryData.description.includes('**UNUSED**')) {
|
||
unusedSubcategories.push(subcategoryName);
|
||
} else {
|
||
regularSubcategories.push(subcategoryName);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render regular subcategories with proper sectioning
|
||
for (const subcategoryName of regularSubcategories) {
|
||
const subcategoryData = subcategories[subcategoryName];
|
||
const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName);
|
||
const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category);
|
||
const subcategoryDescription = subcategoryData.description || 'Subcategory configuration';
|
||
|
||
// Find config items for this subcategory
|
||
const configItems = Object.entries(config)
|
||
.filter(([key, value]) => value.subcategory === subcategoryName)
|
||
.map(([key, value]) => ({ key, ...value }));
|
||
|
||
if (configItems.length > 0) {
|
||
// Check for master toggle in this subcategory
|
||
const masterKey = configItems.find(item => item.master === true);
|
||
|
||
// Find any ENABLED options and use universal toggle renderer
|
||
const enabledKey = configItems.find(item => item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE');
|
||
//console.log('ConfigManager: Checking for toggle - subcategoryName:', subcategoryName, 'enabledKey found:', !!enabledKey, enabledKey ? enabledKey.key : null);
|
||
|
||
// Special handling for domains section
|
||
const isDomains = subcategoryName.includes('domains');
|
||
|
||
if (enabledKey) {
|
||
// Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE
|
||
formHTML += ToggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription, config);
|
||
} else if (masterKey) {
|
||
// Render with master toggle
|
||
formHTML += this.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription);
|
||
} else if (isDomains) {
|
||
// Render domains section with special handling
|
||
formHTML += await this.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription);
|
||
} else {
|
||
// Render regular subcategory with proper sectioning
|
||
formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render advanced subcategories (hidden by default)
|
||
if (advancedSubcategories.length > 0) {
|
||
// Add danger zone toggle for advanced sections
|
||
formHTML += ConfigShared.generateToggleControls(true, false);
|
||
|
||
// Generate advanced sections using shared functionality
|
||
const advancedGroupedConfigs = {};
|
||
advancedSubcategories.forEach(subcategoryName => {
|
||
const configItems = Object.entries(config)
|
||
.filter(([key, value]) => value.subcategory === subcategoryName)
|
||
.map(([key, value]) => ({ key, ...value }));
|
||
|
||
if (configItems.length > 0) {
|
||
advancedGroupedConfigs[subcategoryName] = configItems.map(item => item.key);
|
||
}
|
||
});
|
||
|
||
formHTML += await ConfigShared.generateAdvancedSections(
|
||
advancedSubcategories,
|
||
advancedGroupedConfigs,
|
||
config,
|
||
(category) => this.cleanDescription(subcategories[category]?.description || 'Advanced settings')
|
||
);
|
||
}
|
||
|
||
// Render unused subcategories (hidden by default)
|
||
if (unusedSubcategories.length > 0) {
|
||
// Add unused section toggle
|
||
formHTML += ConfigShared.generateToggleControls(false, true);
|
||
|
||
// Wrap unused sections in hidden container
|
||
formHTML += `
|
||
<div id="unused-sections" class="unused-sections" style="display: none;">
|
||
<div class="section-divider">
|
||
<h3>🗑️ Unused Configuration</h3>
|
||
<p>Deprecated or unused settings</p>
|
||
</div>
|
||
`;
|
||
|
||
// Generate unused sections
|
||
for (const subcategoryName of unusedSubcategories) {
|
||
const subcategoryData = subcategories[subcategoryName];
|
||
const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName);
|
||
const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category);
|
||
const subcategoryDescription = this.cleanDescription(subcategoryData.description || 'Unused configuration');
|
||
|
||
// Find config items for this subcategory
|
||
const configItems = Object.entries(config)
|
||
.filter(([key, value]) => value.subcategory === subcategoryName)
|
||
.map(([key, value]) => ({ key, ...value }));
|
||
|
||
if (configItems.length > 0) {
|
||
// Check if this is a backup section that needs toggle
|
||
const isRemoteBackup = subcategoryName.includes('remote_backup');
|
||
const isFullBackup = subcategoryName.includes('full_backup');
|
||
const backupKey = (isRemoteBackup || isFullBackup) ? configItems.find(item => item.key.includes('ENABLED')) : null;
|
||
|
||
// Check if this is a mail section that needs toggle
|
||
const isMail = subcategoryName.includes('mail_server');
|
||
const mailKey = isMail ? configItems.find(item => item.key.includes('ENABLED')) : null;
|
||
|
||
// Check if this is a Git section that needs toggle
|
||
const isGit = subcategoryName.includes('install');
|
||
const gitKey = isGit ? configItems.find(item => item.key === 'CFG_INSTALL_MODE') : null;
|
||
|
||
if (backupKey) {
|
||
// Render backup section with special toggle
|
||
formHTML += this.renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config);
|
||
} else if (mailKey) {
|
||
// Render mail section with special toggle
|
||
formHTML += this.renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config);
|
||
} else if (gitKey) {
|
||
// Render Git section with special toggle
|
||
formHTML += this.renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config);
|
||
} else {
|
||
// Render regular unused section
|
||
formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, subcategoryName);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Close the unused sections container
|
||
formHTML += `
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
// Fall back to original categorization system
|
||
const categorized = ConfigShared.categorizeConfigs(config);
|
||
const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized;
|
||
|
||
// Render regular categories (always visible)
|
||
for (const cat of regularCategories) {
|
||
const keys = groupedConfigs[cat];
|
||
if (keys && keys.length > 0 && cat !== 'Hidden/Unused Options') {
|
||
const displayCategory = ConfigShared.formatCategoryName(cat);
|
||
const categoryDescription = await ConfigShared.getCategoryDescription(cat);
|
||
|
||
// Check if this category has a master toggle (any key with master: true)
|
||
const masterKey = keys.find(key => {
|
||
const configItem = config[key] || {};
|
||
return configItem.master === true;
|
||
});
|
||
|
||
if (masterKey) {
|
||
// Dynamic master toggle handling
|
||
const masterValue = config[masterKey]?.value || 'false';
|
||
const isMasterEnabled = masterValue === 'true';
|
||
const masterTitle = config[masterKey]?.title || ConfigShared.formatConfigLabel(masterKey);
|
||
const masterDescription = config[masterKey]?.description || '';
|
||
const sectionId = `${cat.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||
const toggleId = `${masterKey.toLowerCase()}-toggle`;
|
||
|
||
formHTML += `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<div>
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
</div>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox" id="${toggleId}" name="${masterKey}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${masterTitle}
|
||
<span class="tooltip" title="${masterDescription}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all other fields (excluding the master toggle)
|
||
keys.filter(key => key !== masterKey).forEach(key => {
|
||
const configItem = config[key] || {};
|
||
const value = configItem.value || '';
|
||
const title = configItem.title || ConfigShared.formatConfigLabel(key);
|
||
const description = configItem.description || '';
|
||
const options = configItem.options || '';
|
||
const fieldId = `config-${key}`;
|
||
formHTML += ConfigShared.generateField(fieldId, key, value, title, description, options, config);
|
||
});
|
||
|
||
formHTML += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
} else if (cat === 'DOMAINS') {
|
||
// Check if Traefik is installed
|
||
const traefikInstalled = await this.checkTraefikInstallation();
|
||
|
||
// Always show the domains section, but add a warning banner if Traefik is not installed
|
||
formHTML += `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
`;
|
||
|
||
if (!traefikInstalled) {
|
||
// Show smaller warning banner
|
||
formHTML += `
|
||
<div class="traefik-warning-banner">
|
||
<div class="traefik-warning-content">
|
||
<span class="traefik-warning-icon">⚠️</span>
|
||
<div class="traefik-warning-text">
|
||
<strong>Traefik Not Installed</strong> - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
formHTML += `<div class="domain-building-blocks">`;
|
||
|
||
// Only show domains that have content (non-empty values)
|
||
const allDomainKeys = keys.filter(key => key.startsWith('CFG_DOMAIN_'));
|
||
const domainKeysWithContent = allDomainKeys.filter(key => {
|
||
const configItem = config[key] || {};
|
||
const value = configItem.value || '';
|
||
return value.trim() !== '';
|
||
});
|
||
|
||
// Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots)
|
||
const isMaxDomains = domainKeysWithContent.length >= 9;
|
||
|
||
domainKeysWithContent.forEach(key => {
|
||
const configItem = config[key] || {};
|
||
const value = configItem.value || '';
|
||
const title = configItem.title || ConfigShared.formatConfigLabel(key);
|
||
const fieldId = `config-${key}`;
|
||
|
||
// Extract domain number
|
||
const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]);
|
||
const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k =>
|
||
parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1])
|
||
));
|
||
|
||
// Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted
|
||
const canDelete = isHighestDomain && domainNum !== 1;
|
||
|
||
formHTML += `
|
||
<div class="domain-building-block">
|
||
<div class="domain-header">
|
||
${ConfigShared.generateField(fieldId, key, value, title, '', {
|
||
placeholder: 'example.com',
|
||
className: 'domain-input'
|
||
})}
|
||
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
|
||
onclick="window.configManager.deleteDomain('${key}', this)"
|
||
title="${canDelete ? 'Delete domain' : domainNum === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'}"
|
||
${!canDelete ? 'disabled' : ''}>
|
||
<span class="delete-icon">×</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
formHTML += `
|
||
</div>
|
||
<div class="domain-actions">
|
||
<button type="button" id="add-domain-btn" class="btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}"
|
||
onclick="window.configManager.addNewDomain()"
|
||
${isMaxDomains ? 'disabled' : ''}>
|
||
<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>
|
||
${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Regular category handling (no master toggle)
|
||
formHTML += `
|
||
<div class="config-category">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
<div class="config-group">
|
||
${ConfigShared.generateFieldsForCategory(keys, cat, config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add danger zone before advanced/unused sections (so content appears below)
|
||
formHTML += ConfigShared.generateToggleControls(
|
||
advancedCategories.length > 0,
|
||
unusedCategories.length > 0
|
||
);
|
||
|
||
// Add advanced and unused sections using shared functionality
|
||
formHTML += await ConfigShared.generateAdvancedSections(
|
||
advancedCategories,
|
||
groupedConfigs,
|
||
config,
|
||
(category) => ConfigShared.getCategoryDescription(category)
|
||
);
|
||
|
||
formHTML += await ConfigShared.generateUnusedSections(
|
||
unusedCategories,
|
||
groupedConfigs,
|
||
config,
|
||
(category) => ConfigShared.getCategoryDescription(category)
|
||
);
|
||
|
||
formHTML += `
|
||
</form>
|
||
<div class="config-actions">
|
||
<button type="button" class="btn btn-primary" onclick="ConfigManager.saveConfig('${category}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||
<polyline points="7 3 7 8 15 8"></polyline>
|
||
</svg>
|
||
Save Configuration
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" onclick="ConfigManager.resetConfig('${category}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="1 4 1 10 7 10"></polyline>
|
||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||
</svg>
|
||
Reset to Defaults
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
configSection.innerHTML = formHTML;
|
||
|
||
// Initialize all master toggles dynamically
|
||
setTimeout(() => {
|
||
// Find all master toggle checkboxes (any input with id ending in "-toggle" where the name ends with "_ENABLED")
|
||
const masterToggles = document.querySelectorAll('input[id$="-toggle"][name$="_ENABLED"]');
|
||
|
||
masterToggles.forEach(toggle => {
|
||
const sectionId = toggle.id.replace('-toggle', '');
|
||
const section = document.getElementById(`section-content-${sectionId}`);
|
||
|
||
if (section && typeof ConfigShared.toggleSectionVisibility === 'function') {
|
||
// Initialize the section state based on the toggle
|
||
ConfigShared.toggleSectionVisibility(`section-content-${sectionId}`, toggle.checked);
|
||
}
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
// Check if Traefik is installed
|
||
async checkTraefikInstallation() {
|
||
try {
|
||
// Use the generic app installation checker
|
||
return await DataLoader.isAppInstalled('traefik');
|
||
} catch (error) {
|
||
//console.log('Traefik check failed:', error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Domain management functions
|
||
addNewDomain() {
|
||
//console.log('Add Domain button clicked!');
|
||
|
||
try {
|
||
// Find the highest existing domain number
|
||
const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]');
|
||
const domainNumbers = [];
|
||
|
||
domainInputs.forEach(input => {
|
||
const match = input.name.match(/CFG_DOMAIN_(\d+)/);
|
||
if (match) {
|
||
domainNumbers.push(parseInt(match[1]));
|
||
}
|
||
});
|
||
|
||
// Check if we've reached the maximum of 9 domains
|
||
if (domainNumbers.length >= 9) {
|
||
//console.log('Maximum of 9 domains reached');
|
||
return;
|
||
}
|
||
|
||
const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1;
|
||
const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`;
|
||
const newFieldId = `config-${newDomainKey}`;
|
||
|
||
// Create new domain building block HTML
|
||
const newDomainHTML = `
|
||
<div class="domain-building-block">
|
||
<div class="domain-header">
|
||
${ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })}
|
||
<button type="button" class="delete-domain-btn"
|
||
onclick="window.configManager.deleteDomain('${newDomainKey}', this)">
|
||
<span class="delete-icon">×</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Find the domain-building-blocks container and add the new block
|
||
const domainContainer = document.querySelector('.domain-building-blocks');
|
||
if (domainContainer) {
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = newDomainHTML;
|
||
const newBlock = tempDiv.firstElementChild;
|
||
domainContainer.appendChild(newBlock);
|
||
|
||
// Focus on the new input field
|
||
const newInput = newBlock.querySelector('input');
|
||
if (newInput) {
|
||
newInput.focus();
|
||
}
|
||
|
||
// Update add domain button state
|
||
const addBtn = document.getElementById('add-domain-btn');
|
||
if (addBtn) {
|
||
const totalDomains = document.querySelectorAll('.domain-building-block').length;
|
||
if (totalDomains >= 9) {
|
||
addBtn.disabled = true;
|
||
addBtn.className = 'btn btn-secondary';
|
||
addBtn.innerHTML = '<span class="add-icon">✓</span>Maximum Domains Reached';
|
||
}
|
||
}
|
||
|
||
// Update delete button states (only highest numbered domain should be deletable)
|
||
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
|
||
allDomainBlocks.forEach((block, index) => {
|
||
const deleteBtn = block.querySelector('.delete-domain-btn');
|
||
if (deleteBtn) {
|
||
const input = block.querySelector('input');
|
||
const inputName = input ? input.name : '';
|
||
const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/);
|
||
const domainNumber = domainNum ? parseInt(domainNum[1]) : 0;
|
||
|
||
// Find the highest domain number among all visible blocks
|
||
const allDomainNumbers = Array.from(allDomainBlocks).map(b => {
|
||
const inp = b.querySelector('input');
|
||
const name = inp ? inp.name : '';
|
||
const match = name.match(/CFG_DOMAIN_(\d+)/);
|
||
return match ? parseInt(match[1]) : 0;
|
||
});
|
||
const highestDomainNumber = Math.max(...allDomainNumbers);
|
||
|
||
// Only highest numbered domain can be deleted, but NEVER Domain 1
|
||
const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1;
|
||
|
||
deleteBtn.disabled = !canDelete;
|
||
deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`;
|
||
deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain';
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding new domain:', error);
|
||
}
|
||
}
|
||
|
||
deleteDomain(domainKey, buttonElement) {
|
||
//console.log(`Delete domain button clicked for: ${domainKey}`);
|
||
|
||
try {
|
||
// Find the domain-building-block and remove it
|
||
const domainBlock = buttonElement.closest('.domain-building-block');
|
||
if (domainBlock) {
|
||
// Clear the input value first
|
||
const input = domainBlock.querySelector('input');
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
// Remove the entire building block
|
||
domainBlock.remove();
|
||
|
||
// Update add domain button state (re-enable if we're below 9 domains)
|
||
const addBtn = document.getElementById('add-domain-btn');
|
||
if (addBtn) {
|
||
const totalDomains = document.querySelectorAll('.domain-building-block').length;
|
||
if (totalDomains < 9) {
|
||
addBtn.disabled = false;
|
||
addBtn.className = 'btn btn-primary';
|
||
addBtn.innerHTML = '<span class="add-icon">+</span>Add Domain';
|
||
}
|
||
}
|
||
|
||
// Update delete button states (only highest numbered domain should be deletable)
|
||
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
|
||
allDomainBlocks.forEach((block, index) => {
|
||
const deleteBtn = block.querySelector('.delete-domain-btn');
|
||
if (deleteBtn) {
|
||
const input = block.querySelector('input');
|
||
const inputName = input ? input.name : '';
|
||
const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/);
|
||
const domainNumber = domainNum ? parseInt(domainNum[1]) : 0;
|
||
|
||
// Find the highest domain number among all visible blocks
|
||
const allDomainNumbers = Array.from(allDomainBlocks).map(b => {
|
||
const inp = b.querySelector('input');
|
||
const name = inp ? inp.name : '';
|
||
const match = name.match(/CFG_DOMAIN_(\d+)/);
|
||
return match ? parseInt(match[1]) : 0;
|
||
});
|
||
const highestDomainNumber = Math.max(...allDomainNumbers);
|
||
|
||
// Only highest numbered domain can be deleted, but NEVER Domain 1
|
||
const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1;
|
||
|
||
deleteBtn.disabled = !canDelete;
|
||
deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`;
|
||
deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain';
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting domain:', error);
|
||
}
|
||
}
|
||
|
||
async loadScript(src) {
|
||
const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_');
|
||
const existingScript = document.getElementById(scriptId);
|
||
|
||
if (existingScript && src.includes('config-shared.js')) {
|
||
existingScript.remove();
|
||
} else if (existingScript) {
|
||
return;
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = src;
|
||
script.id = scriptId;
|
||
script.onload = resolve;
|
||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
formatCategoryName(category) {
|
||
return category
|
||
.split('_')
|
||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||
.join(' ');
|
||
}
|
||
|
||
static async saveConfig(category) {
|
||
const form = document.getElementById(`config-form-${category}`);
|
||
if (!form) return;
|
||
|
||
const formData = new FormData(form);
|
||
const config = {};
|
||
|
||
for (const [key, value] of formData.entries()) {
|
||
const checkbox = form.querySelector(`input[name="${key}"][type="checkbox"]`);
|
||
if (checkbox) {
|
||
config[key] = checkbox.checked;
|
||
} else {
|
||
config[key] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
static async resetConfig(category) {
|
||
if (confirm('Are you sure you want to reset all settings to their default values?')) {
|
||
window.location.reload();
|
||
}
|
||
}
|
||
|
||
// Helper method to render subcategory with master toggle
|
||
renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription, isAdvanced = false) {
|
||
const masterValue = masterKey.value || 'false';
|
||
const isMasterEnabled = masterValue === 'true';
|
||
const masterTitle = masterKey.title || ConfigShared.formatConfigLabel(masterKey.key);
|
||
const masterDescription = masterKey.description || '';
|
||
const sectionId = `${displaySubcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||
const toggleId = `${masterKey.key.toLowerCase()}-toggle`;
|
||
|
||
let html = `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<div>
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
</div>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox" id="${toggleId}" name="${masterKey.key}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${masterTitle}
|
||
<span class="tooltip" title="${masterDescription}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all other fields (excluding the master toggle)
|
||
configItems.filter(item => item.key !== masterKey.key).forEach(item => {
|
||
const fieldId = `config-${item.key}`;
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Helper method to render domains section with special handling
|
||
async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) {
|
||
// Check if Traefik is installed
|
||
const traefikInstalled = await this.checkTraefikInstallation();
|
||
|
||
let html = `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
`;
|
||
|
||
if (!traefikInstalled) {
|
||
// Show smaller warning banner
|
||
html += `
|
||
<div class="traefik-warning-banner">
|
||
<div class="traefik-warning-content">
|
||
<span class="traefik-warning-icon">⚠️</span>
|
||
<div class="traefik-warning-text">
|
||
<strong>Traefik Not Installed</strong> - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += `<div class="domain-building-blocks">`;
|
||
|
||
// Only show domains that have content (non-empty values)
|
||
const allDomainKeys = configItems.filter(item => item.key.startsWith('CFG_DOMAIN_'));
|
||
const domainKeysWithContent = allDomainKeys.filter(item => {
|
||
const value = item.value || '';
|
||
return value.trim() !== '';
|
||
});
|
||
|
||
// Only render domains that have content
|
||
domainKeysWithContent.forEach(item => {
|
||
const value = item.value || '';
|
||
const title = item.title || ConfigShared.formatConfigLabel(item.key);
|
||
const fieldId = `config-${item.key}`;
|
||
|
||
// Extract domain number
|
||
const domainNum = parseInt(item.key.match(/CFG_DOMAIN_(\d+)/)[1]);
|
||
const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k =>
|
||
parseInt(k.key.match(/CFG_DOMAIN_(\d+)/)[1])
|
||
));
|
||
|
||
// Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted
|
||
const canDelete = isHighestDomain && domainNum !== 1;
|
||
|
||
html += `
|
||
<div class="domain-building-block">
|
||
<div class="domain-header">
|
||
${ConfigShared.generateField(fieldId, item.key, value, title, '', {
|
||
placeholder: 'example.com',
|
||
className: 'domain-input',
|
||
onchange: 'window.configManager.validateDomainFormat(this, true)',
|
||
oninput: 'window.configManager.validateDomainFormat(this, true)',
|
||
onblur: 'window.configManager.validateDomainFormat(this, true)'
|
||
})}
|
||
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
|
||
onclick="window.configManager.deleteDomain('${item.key}', this)"
|
||
title="${canDelete ? 'Delete domain' : domainNum === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'}"
|
||
${!canDelete ? 'disabled' : ''}>
|
||
<span class="delete-icon">×</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// Add "Add Domain" button outside the grid
|
||
const isMaxDomains = domainKeysWithContent.length >= 9;
|
||
html += `
|
||
</div>
|
||
<div class="domain-actions">
|
||
<button type="button" class="add-domain-btn ${isMaxDomains ? 'disabled' : ''}"
|
||
onclick="window.configManager.addDomain(this)"
|
||
${isMaxDomains ? 'disabled' : ''}>
|
||
<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>
|
||
<span class="add-text">${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Helper method to render remote backup section with toggle
|
||
renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
|
||
const isEnabled = backupKey.value === 'true';
|
||
const sectionId = `backup-${backupKey.key}`;
|
||
const toggleId = `${backupKey.key.toLowerCase()}-toggle`;
|
||
|
||
let html = `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<div>
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
</div>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox"
|
||
id="${toggleId}"
|
||
name="${backupKey.key}"
|
||
value="true"
|
||
${isEnabled ? 'checked' : ''}
|
||
data-toggle-config="${backupKey.key}"
|
||
data-section-id="section-content-${sectionId}"
|
||
data-toggle-type="checkbox"
|
||
onchange="window.toggleManager.toggle('${backupKey.key}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${backupKey.title || 'Enable Remote Backup'}
|
||
<span class="tooltip" title="${backupKey.description || 'Enable this remote backup location'}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all other fields (excluding the ENABLED toggle)
|
||
configItems.filter(item => item.key !== backupKey.key).forEach(item => {
|
||
const fieldId = `config-${item.key}`;
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Helper method to render mail section with toggle
|
||
renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
|
||
const isEnabled = mailKey.value === 'true';
|
||
const sectionId = `mail-${mailKey.key}`;
|
||
const toggleId = `${mailKey.key.toLowerCase()}-toggle`;
|
||
|
||
let html = `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<div>
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
</div>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox"
|
||
id="${toggleId}"
|
||
name="${mailKey.key}"
|
||
value="true"
|
||
${isEnabled ? 'checked' : ''}
|
||
data-toggle-config="${mailKey.key}"
|
||
data-section-id="section-content-${sectionId}"
|
||
data-toggle-type="checkbox"
|
||
onchange="window.toggleManager.toggle('${mailKey.key}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${mailKey.title || 'Enable Mail Configuration'}
|
||
<span class="tooltip" title="${mailKey.description || 'Enable mail server configuration for applications'}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all other fields (excluding the ENABLED toggle)
|
||
configItems.filter(item => item.key !== mailKey.key).forEach(item => {
|
||
const fieldId = `config-${item.key}`;
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
|
||
});
|
||
|
||
// Add test connection button after all mail fields
|
||
html += `
|
||
<div class="field-group">
|
||
<button type="button" class="test-connection-btn" onclick="window.configManager.testMailConnection('${mailKey.key}')">
|
||
<span class="test-icon">📧</span>
|
||
<span class="test-text">Test Mail Connection</span>
|
||
</button>
|
||
<div id="mail-test-result" class="test-result" style="display: none;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Test mail server connection
|
||
async testMailConnection(mailKey) {
|
||
const resultDiv = document.getElementById('mail-test-result');
|
||
const button = event.target.closest('button');
|
||
|
||
// Show loading state
|
||
button.disabled = true;
|
||
button.innerHTML = '<span class="test-icon">⏳</span><span class="test-text">Testing...</span>';
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.className = 'test-result testing';
|
||
resultDiv.innerHTML = 'Testing mail server connection...';
|
||
|
||
// Initialize mailConfig outside try block for catch block access
|
||
let mailConfig = {};
|
||
|
||
try {
|
||
// Get current mail configuration values from the form
|
||
mailConfig = {
|
||
host: document.querySelector('input[name="CFG_MAIL_HOST"]')?.value || '',
|
||
port: document.querySelector('input[name="CFG_MAIL_PORT"]')?.value || '',
|
||
secure: document.querySelector('select[name="CFG_MAIL_SECURE"]')?.value || '',
|
||
username: document.querySelector('input[name="CFG_MAIL_USERNAME"]')?.value || '',
|
||
password: document.querySelector('input[name="CFG_MAIL_PASSWORD"]')?.value || '',
|
||
from: document.querySelector('input[name="CFG_MAIL_FROM"]')?.value || ''
|
||
};
|
||
|
||
// Validate required fields
|
||
if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) {
|
||
throw new Error('Please fill in all required mail server fields (host, port, username, password)');
|
||
}
|
||
|
||
// Call backend test script
|
||
const response = await fetch('/api/test-mail-connection', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(mailConfig)
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
resultDiv.className = 'test-result success';
|
||
resultDiv.innerHTML = `✅ ${result.message || 'Mail server connection successful!'}${result.details ? `<br><small>${result.details}</small>` : ''}`;
|
||
} else {
|
||
resultDiv.className = 'test-result error';
|
||
let errorHtml = `❌ ${result.message || 'Mail server connection failed'}`;
|
||
|
||
// Add detailed error information directly underneath
|
||
if (result.details || result.error || result.config) {
|
||
errorHtml += `
|
||
<div style="margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; font-family: monospace; font-size: 11px; white-space: pre-wrap;">
|
||
<strong>Error Details:</strong><br>
|
||
${result.details || result.error || 'No additional details available'}
|
||
${result.stack ? `<br><br><strong>Stack Trace:</strong><br>${result.stack}` : ''}
|
||
${result.config ? `<br><br><strong>Connection Config:</strong><br>${JSON.stringify(result.config, null, 2)}` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
resultDiv.innerHTML = errorHtml;
|
||
}
|
||
|
||
} catch (error) {
|
||
resultDiv.className = 'test-result error';
|
||
resultDiv.innerHTML = `
|
||
❌ ${error.message || 'Failed to test mail connection'}
|
||
<div style="margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; font-family: monospace; font-size: 11px; white-space: pre-wrap;">
|
||
<strong>Error Details:</strong><br>
|
||
${error.message || 'Unknown error'}
|
||
${error.stack ? `<br><br><strong>Stack Trace:</strong><br>${error.stack}` : ''}
|
||
${error.response ? `<br><br><strong>Response:</strong><br>${JSON.stringify(error.response, null, 2)}` : ''}
|
||
${mailConfig ? `<br><br><strong>Mail Config:</strong><br>${JSON.stringify({...mailConfig, password: mailConfig.password ? '[REDACTED]' : undefined}, null, 2)}` : ''}
|
||
</div>
|
||
`;
|
||
} finally {
|
||
// Restore button state
|
||
button.disabled = false;
|
||
button.innerHTML = '<span class="test-icon">📧</span><span class="test-text">Test Mail Connection</span>';
|
||
}
|
||
}
|
||
|
||
// Helper method to render Git section with toggle
|
||
renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
|
||
// CFG_INSTALL_MODE controls git section: 'git' = enabled, 'local' = disabled
|
||
const isEnabled = gitKey.value === 'git';
|
||
const sectionId = `git-${gitKey.key}`;
|
||
const toggleId = `${gitKey.key.toLowerCase()}-toggle`;
|
||
|
||
let html = `
|
||
<div class="config-category">
|
||
<div class="domains-wrapper">
|
||
<div class="domains-header">
|
||
<div>
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
</div>
|
||
</div>
|
||
<div class="domains-divider"></div>
|
||
<div class="config-fields">
|
||
<div class="field-group">
|
||
<label for="${toggleId}">
|
||
${gitKey.title || 'Install Mode'}
|
||
<span class="tooltip" title="${gitKey.description || 'Choose between Git repository or local folder installation'}">ℹ️</span>
|
||
</label>
|
||
<select id="${toggleId}"
|
||
name="${gitKey.key}"
|
||
class="form-control"
|
||
data-toggle-config="${gitKey.key}"
|
||
data-section-id="section-content-${sectionId}"
|
||
data-toggle-type="select"
|
||
onchange="window.toggleManager.toggle('${gitKey.key}', this.value === 'git')">
|
||
${ConfigOptions.getSelectOptions('CFG_INSTALL_MODE').map(opt => `<option value="${opt.value}" ${opt.value === gitKey.value ? 'selected' : ''}>${opt.label}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all other git fields (excluding the CFG_INSTALL_MODE itself)
|
||
configItems.filter(item => item.key !== gitKey.key && item.key.startsWith('CFG_GIT_')).forEach(item => {
|
||
const fieldId = `config-${item.key}`;
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Helper method to clean description text by removing tags
|
||
cleanDescription(description) {
|
||
return description
|
||
.replace(/\*\*ADVANCED\*\*/g, '')
|
||
.replace(/\*\*UNUSED\*\*/g, '')
|
||
.replace(/^\s+|\s+$/g, '') // Trim whitespace
|
||
.replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space
|
||
}
|
||
|
||
// Update all domain delete button states
|
||
updateDomainDeleteButtons() {
|
||
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
|
||
|
||
// Find the highest domain number (regardless of content)
|
||
const domainNumbers = Array.from(allDomainBlocks).map(block => {
|
||
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
|
||
if (input) {
|
||
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
|
||
return match ? parseInt(match[1]) : 0;
|
||
}
|
||
return 0;
|
||
}).filter(num => num > 0);
|
||
|
||
const highestDomain = Math.max(...domainNumbers, 0);
|
||
|
||
// Update delete buttons
|
||
allDomainBlocks.forEach(block => {
|
||
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
|
||
const deleteBtn = block.querySelector('.delete-domain-btn');
|
||
|
||
if (input && deleteBtn) {
|
||
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
|
||
const domainNum = match ? parseInt(match[1]) : 0;
|
||
|
||
// SIMPLE RULE: Only highest numbered domain can be deleted (except Domain 1)
|
||
const canDelete = domainNum === highestDomain && domainNum !== 1;
|
||
|
||
if (canDelete) {
|
||
deleteBtn.classList.remove('disabled');
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.title = 'Delete domain';
|
||
} else {
|
||
deleteBtn.classList.add('disabled');
|
||
deleteBtn.disabled = true;
|
||
if (domainNum === 1) {
|
||
deleteBtn.title = 'Domain 1 cannot be deleted';
|
||
} else {
|
||
deleteBtn.title = 'Can only delete highest numbered domain';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Validate domain format when user tries to add a new domain
|
||
validateDomainFormat(input, showNotifications = true) {
|
||
const value = input.value.trim();
|
||
if (!value) {
|
||
return true; // Allow empty for initial state
|
||
}
|
||
|
||
// Basic domain validation regex - supports subdomains and multiple TLD levels
|
||
const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/;
|
||
const isValidFormat = domainRegex.test(value);
|
||
|
||
// Check for duplicates
|
||
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
|
||
const duplicates = Array.from(allDomainInputs).filter(otherInput => {
|
||
if (otherInput === input) return false; // Skip self
|
||
return otherInput.value.trim().toLowerCase() === value.toLowerCase();
|
||
});
|
||
const hasDuplicate = duplicates.length > 0;
|
||
|
||
if (!isValidFormat) {
|
||
input.style.borderColor = '#dc3545';
|
||
input.title = 'Invalid domain format (e.g., example.com)';
|
||
if (showNotifications && window.notificationSystem) {
|
||
window.notificationSystem.error(`Invalid domain format: "${value}". Please use a valid domain like example.com`);
|
||
}
|
||
return false;
|
||
} else if (hasDuplicate) {
|
||
input.style.borderColor = '#ffc107';
|
||
input.title = 'Domain already exists!';
|
||
if (showNotifications && window.notificationSystem) {
|
||
window.notificationSystem.warning(`Domain "${value}" already exists. Please use a unique domain.`);
|
||
}
|
||
return false;
|
||
} else {
|
||
input.style.borderColor = '';
|
||
input.title = '';
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Validate email format for mail fields
|
||
validateEmailFormat(input, showNotifications = true) {
|
||
const value = input.value.trim();
|
||
if (!value) {
|
||
return true; // Allow empty for initial state
|
||
}
|
||
|
||
// Email validation regex
|
||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||
const isValidFormat = emailRegex.test(value);
|
||
|
||
if (!isValidFormat) {
|
||
input.style.borderColor = '#dc3545';
|
||
input.title = 'Invalid email format (e.g., user@example.com)';
|
||
if (showNotifications && window.notificationSystem) {
|
||
window.notificationSystem.error(`Invalid email format: "${value}". Please use a valid email like user@example.com`);
|
||
}
|
||
return false;
|
||
} else {
|
||
input.style.borderColor = '';
|
||
input.title = '';
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Validate hostname format for mail server
|
||
validateHostnameFormat(input, showNotifications = true) {
|
||
const value = input.value.trim();
|
||
if (!value) {
|
||
return true; // Allow empty for initial state
|
||
}
|
||
|
||
// Hostname validation regex - allows subdomains and multiple TLD levels
|
||
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/;
|
||
const isValidFormat = hostnameRegex.test(value);
|
||
|
||
if (!isValidFormat) {
|
||
input.style.borderColor = '#dc3545';
|
||
input.title = 'Invalid hostname format (e.g., mail.domain.com)';
|
||
if (showNotifications && window.notificationSystem) {
|
||
window.notificationSystem.error(`Invalid hostname format: "${value}". Please use a valid hostname like mail.domain.com`);
|
||
}
|
||
return false;
|
||
} else {
|
||
input.style.borderColor = '';
|
||
input.title = '';
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Validate port number for mail server
|
||
validatePortNumber(input, showNotifications = true) {
|
||
const value = input.value.trim();
|
||
if (!value) {
|
||
return true; // Allow empty for initial state
|
||
}
|
||
|
||
const port = parseInt(value, 10);
|
||
const isValidPort = !isNaN(port) && port >= 1 && port <= 65535;
|
||
|
||
if (!isValidPort) {
|
||
input.style.borderColor = '#dc3545';
|
||
input.title = 'Invalid port number (1-65535)';
|
||
if (showNotifications && window.notificationSystem) {
|
||
window.notificationSystem.error(`Invalid port number: "${value}". Please use a valid port between 1 and 65535`);
|
||
}
|
||
return false;
|
||
} else {
|
||
input.style.borderColor = '';
|
||
input.title = '';
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Check if all domains are valid before allowing new domain addition
|
||
canAddNewDomain() {
|
||
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
|
||
for (const input of allDomainInputs) {
|
||
if (!this.validateDomainFormat(input, true)) { // Don't show notifications during bulk check
|
||
return false; // At least one domain has invalid format or duplicate
|
||
}
|
||
}
|
||
return true; // All domains are valid
|
||
}
|
||
|
||
// Add a new domain field
|
||
addDomain(button) {
|
||
// Find all existing domain blocks
|
||
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
|
||
const domainData = Array.from(allDomainBlocks).map(block => {
|
||
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
|
||
if (input) {
|
||
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
|
||
return {
|
||
num: match ? parseInt(match[1]) : 0,
|
||
value: input.value.trim(),
|
||
input: input,
|
||
block: block
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(item => item !== null);
|
||
|
||
// Sort by domain number
|
||
domainData.sort((a, b) => a.num - b.num);
|
||
|
||
// Find the highest domain number with content
|
||
const domainsWithContent = domainData.filter(d => d.value);
|
||
const highestDomainWithContent = domainsWithContent.length > 0 ?
|
||
Math.max(...domainsWithContent.map(d => d.num)) : 0;
|
||
|
||
// Find the highest domain number overall (including empty)
|
||
const highestDomainOverall = Math.max(...domainData.map(d => d.num), 0);
|
||
|
||
// Check if the highest domain (overall) is empty
|
||
const highestDomainData = domainData.find(d => d.num === highestDomainOverall);
|
||
if (highestDomainData && !highestDomainData.value) {
|
||
// Flash the empty highest domain without validation checks
|
||
const emptyInput = document.querySelector(`input[id="config-CFG_DOMAIN_${highestDomainOverall}"]`);
|
||
if (emptyInput) {
|
||
emptyInput.style.animation = 'flash 0.5s ease-in-out 2';
|
||
emptyInput.focus();
|
||
setTimeout(() => {
|
||
emptyInput.style.animation = '';
|
||
}, 1000);
|
||
return;
|
||
}
|
||
|
||
// Remove animation after it completes
|
||
setTimeout(() => {
|
||
input.style.animation = '';
|
||
}, 1000);
|
||
|
||
return;
|
||
}
|
||
|
||
// Find the next available domain slot (only if highest with content is filled)
|
||
const usedNumbers = domainData.map(d => d.num);
|
||
let nextDomain = 1;
|
||
while (usedNumbers.includes(nextDomain) && nextDomain <= 9) {
|
||
nextDomain++;
|
||
}
|
||
|
||
// Only add if we have domains with content and the highest with content is filled
|
||
if (highestDomainWithContent === 0) {
|
||
// No domains with content yet, this shouldn't happen but handle it
|
||
} else if (nextDomain > 9) {
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.warning('Maximum of 9 domains reached!');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Before adding new domain, validate that all existing domains have valid format
|
||
if (!this.canAddNewDomain()) {
|
||
// Find the first invalid domain and focus it with flash
|
||
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
|
||
for (const input of allDomainInputs) {
|
||
if (!this.validateDomainFormat(input, false)) { // Don't show notification here
|
||
input.style.animation = 'flash 0.5s ease-in-out 2';
|
||
input.focus();
|
||
setTimeout(() => {
|
||
input.style.animation = '';
|
||
}, 1000);
|
||
return; // Just flash and focus, no extra notification
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create new domain field with proper structure
|
||
const domainKey = `CFG_DOMAIN_${nextDomain}`;
|
||
const fieldId = `config-${domainKey}`;
|
||
const title = `Domain ${nextDomain}`;
|
||
|
||
const newDomainHTML = `
|
||
<div class="domain-building-block">
|
||
<div class="domain-header">
|
||
${ConfigShared.generateField(fieldId, domainKey, '', title, '', {
|
||
placeholder: 'example.com',
|
||
className: 'domain-input',
|
||
onchange: 'window.configManager.validateDomainFormat(this, true)',
|
||
oninput: 'window.configManager.validateDomainFormat(this, true)',
|
||
onblur: 'window.configManager.validateDomainFormat(this, true)'
|
||
})}
|
||
<button type="button" class="delete-domain-btn"
|
||
onclick="window.configManager.deleteDomain('${domainKey}', this)"
|
||
title="Delete domain">
|
||
<span class="delete-icon">×</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Insert inside the domain-building-blocks container before the domain-actions
|
||
const domainBlocks = button.closest('.domains-wrapper').querySelector('.domain-building-blocks');
|
||
domainBlocks.insertAdjacentHTML('beforeend', newDomainHTML);
|
||
|
||
// Update all delete button states after DOM is ready
|
||
setTimeout(() => this.updateDomainDeleteButtons(), 10);
|
||
|
||
// Update button state if we're now at max domains
|
||
const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length;
|
||
if (totalDomains >= 9) {
|
||
button.classList.add('disabled');
|
||
button.disabled = true;
|
||
const iconSpan = button.querySelector('.add-icon');
|
||
const textSpan = button.querySelector('.add-text');
|
||
iconSpan.textContent = '✓';
|
||
textSpan.textContent = 'Maximum Domains Reached';
|
||
}
|
||
}
|
||
|
||
// Delete a domain field
|
||
deleteDomain(domainKey, button) {
|
||
const domainBlock = button.closest('.domain-building-block');
|
||
|
||
// Clear the domain value
|
||
const input = document.getElementById(`config-${domainKey}`);
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
|
||
// Remove the domain block if it's empty
|
||
if (!input || input.value === '') {
|
||
domainBlock.remove();
|
||
}
|
||
|
||
// Update all delete button states after DOM is ready
|
||
setTimeout(() => this.updateDomainDeleteButtons(), 10);
|
||
|
||
// Update add button state
|
||
const addButton = document.querySelector('.add-domain-btn');
|
||
if (addButton) {
|
||
const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length;
|
||
const domainsWithContent = Array.from(document.querySelectorAll('[id^="config-CFG_DOMAIN_"]'))
|
||
.filter(input => input.value.trim() !== '').length;
|
||
|
||
if (totalDomains < 9) {
|
||
addButton.classList.remove('disabled');
|
||
addButton.disabled = false;
|
||
const iconSpan = addButton.querySelector('.add-icon');
|
||
const textSpan = addButton.querySelector('.add-text');
|
||
iconSpan.textContent = '+';
|
||
textSpan.textContent = 'Add Domain';
|
||
} else {
|
||
addButton.classList.add('disabled');
|
||
addButton.disabled = true;
|
||
const iconSpan = addButton.querySelector('.add-icon');
|
||
const textSpan = addButton.querySelector('.add-text');
|
||
iconSpan.textContent = '✓';
|
||
textSpan.textContent = 'Maximum Domains Reached';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper method to render subcategory with proper sectioning, dividers and headers
|
||
renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) {
|
||
//console.log(`renderSubcategorySection: subcategory=${subcategoryDescription}, configKeys=${Object.keys(config)}, CFG_INSTALL_MODE=${config.CFG_INSTALL_MODE?.value}`);
|
||
const cleanDescription = this.cleanDescription(subcategoryDescription);
|
||
let html = `
|
||
<div class="config-category">
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${cleanDescription}</p>
|
||
<div class="domains-wrapper">
|
||
<div class="domains-divider"></div>
|
||
<div class="config-fields">
|
||
`;
|
||
|
||
// Add all config items using standard layout
|
||
configItems.forEach((item, index) => {
|
||
const fieldId = `config-${item.key}`;
|
||
const cleanItemDescription = this.cleanDescription(item.description || '');
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, cleanItemDescription, item.options);
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// Helper method to render regular subcategory
|
||
renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) {
|
||
let html = `
|
||
<div class="config-category">
|
||
<h3>${displaySubcategory}</h3>
|
||
<p class="category-description">${subcategoryDescription}</p>
|
||
<div class="config-group">
|
||
`;
|
||
|
||
configItems.forEach(item => {
|
||
const fieldId = `config-${item.key}`;
|
||
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
<div class="spacer spacer-lg"></div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
}
|
||
|
||
// Global instance
|
||
window.configManager = new ConfigManager();
|