feat(luci): KISS theme rework for SMTP Relay and SecuBox Users

- Rewrite smtp-relay/settings.js with proper KISS theme styling
- Rewrite secubox-users/overview.js with proper KISS theme styling
- Use KissTheme.wrap() for consistent dark theme rendering
- Add stat cards with colored values matching mailserver reference
- Add proper form styling with inline CSS variables
- Add NZB tools (SABnzbd, NZBHydra) to KISS menu Downloads
- Add webtorrent to portal tree Downloads category
- Fix portal tree webtorrent pattern

KISS = Keep It Simple Sexy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-16 08:20:12 +01:00
parent ee7cd8ef6f
commit c74ba2e474
4 changed files with 612 additions and 505 deletions

View File

@ -237,7 +237,7 @@ build_tree() {
json_add_array "items"
for app in $apps; do
case "$app" in
luci-app-torrent*|luci-app-droplet*|luci-app-aria*|luci-app-transmission*|luci-app-nzb*|luci-app-sabnzbd*)
luci-app-torrent*|luci-app-droplet*|luci-app-webtorrent*|luci-app-aria*|luci-app-transmission*|luci-app-nzb*|luci-app-sabnzbd*)
local name=$(echo "$app" | sed 's/luci-app-//' | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++)$i=toupper(substr($i,1,1))tolower(substr($i,2))}1')
json_add_object ""
json_add_string "name" "$name"

View File

@ -3,46 +3,43 @@
'require dom';
'require ui';
'require rpc';
'require poll';
'require secubox/kiss-theme';
var callStatus = rpc.declare({
object: 'luci.secubox-users',
method: 'status',
expect: { }
expect: {}
});
var callUsers = rpc.declare({
object: 'luci.secubox-users',
method: 'users',
expect: { }
expect: {}
});
var callAddUser = rpc.declare({
object: 'luci.secubox-users',
method: 'add',
params: ['username', 'password', 'services'],
expect: { }
expect: {}
});
var callDeleteUser = rpc.declare({
object: 'luci.secubox-users',
method: 'delete',
params: ['username'],
expect: { }
expect: {}
});
var callPasswd = rpc.declare({
object: 'luci.secubox-users',
method: 'passwd',
params: ['username', 'password'],
expect: { }
expect: {}
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return Promise.all([
callStatus(),
@ -50,293 +47,335 @@ return view.extend({
]);
},
renderStats: function(status, userCount) {
var c = KissTheme.colors;
var services = status.services || {};
var activeServices = Object.keys(services).filter(function(s) { return services[s]; }).length;
return [
KissTheme.stat(userCount, 'Users', c.blue),
KissTheme.stat(activeServices, 'Services', c.green),
KissTheme.stat(status.domain || 'N/A', 'Domain', c.purple)
];
},
renderServices: function(services) {
var serviceNames = ['nextcloud', 'peertube', 'matrix', 'jabber', 'email', 'gitea', 'jellyfin'];
var badges = serviceNames.map(function(name) {
var running = services[name];
return KissTheme.badge(name.charAt(0).toUpperCase() + name.slice(1), running ? 'green' : 'muted');
});
return E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 8px;' }, badges);
},
renderUserRow: function(user) {
var self = this;
var services = (user.services || []).map(function(s) {
return E('span', {
'style': 'display: inline-block; padding: 2px 6px; margin: 2px; border-radius: 4px; background: var(--kiss-bg); color: var(--kiss-cyan); font-size: 11px;'
}, s);
});
var lastLogin = user.last_login || 'never';
var loginSuccess = user.login_success || 0;
var loginFailure = user.login_failure || 0;
var failColor = loginFailure > 10 ? 'var(--kiss-red)' : (loginFailure > 0 ? 'var(--kiss-orange)' : 'var(--kiss-green)');
return E('tr', {}, [
E('td', { 'style': 'font-weight: 600;' }, user.username),
E('td', { 'style': 'color: var(--kiss-muted);' }, user.email),
E('td', { 'style': 'color: var(--kiss-muted);' }, lastLogin),
E('td', { 'style': 'text-align: center;' }, [
E('span', { 'style': 'color: var(--kiss-green); font-weight: 600;' }, String(loginSuccess)),
E('span', { 'style': 'color: var(--kiss-muted);' }, ' / '),
E('span', { 'style': 'color: ' + failColor + '; font-weight: 600;' }, String(loginFailure))
]),
E('td', {}, services),
E('td', { 'style': 'width: 180px;' }, [
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('button', {
'class': 'kiss-btn',
'style': 'padding: 4px 10px; font-size: 11px;',
'click': function() { self.handlePasswd(user.username); }
}, 'Password'),
E('button', {
'class': 'kiss-btn kiss-btn-red',
'style': 'padding: 4px 10px; font-size: 11px;',
'click': function() { self.handleDelete(user.username); }
}, 'Delete')
])
])
]);
},
renderUsersTable: function(users) {
var self = this;
if (!users || users.length === 0) {
return E('p', { 'style': 'color: var(--kiss-muted);' }, 'No users configured. Click "Add User" to create one.');
}
var rows = users.map(function(u) { return self.renderUserRow(u); });
return E('table', { 'class': 'kiss-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Username'),
E('th', {}, 'Email'),
E('th', {}, 'Last Login'),
E('th', { 'style': 'text-align: center;' }, 'OK / Fail'),
E('th', {}, 'Services'),
E('th', {}, 'Actions')
])
]),
E('tbody', {}, rows)
]);
},
handleAdd: function() {
var self = this;
ui.showModal('Add User', [
E('div', { 'style': 'padding: 16px;' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 16px;' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 6px;' }, [
E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted);' }, 'Username'),
E('input', {
'type': 'text',
'id': 'new-username',
'style': 'background: var(--kiss-bg); border: 1px solid var(--kiss-line); color: var(--kiss-text); padding: 10px 12px; border-radius: 6px;'
})
]),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 6px;' }, [
E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted);' }, 'Password'),
E('input', {
'type': 'password',
'id': 'new-password',
'placeholder': 'Leave empty to generate',
'style': 'background: var(--kiss-bg); border: 1px solid var(--kiss-line); color: var(--kiss-text); padding: 10px 12px; border-radius: 6px;'
})
]),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 6px;' }, [
E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted);' }, 'Services'),
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 12px;' }, [
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-nextcloud', 'checked': true }), 'Nextcloud']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-peertube', 'checked': true }), 'PeerTube']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-jabber', 'checked': true }), 'Jabber']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-matrix', 'checked': true }), 'Matrix']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-email', 'checked': true }), 'Email']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-gitea', 'checked': true }), 'Gitea']),
E('label', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [E('input', { 'type': 'checkbox', 'id': 'svc-jellyfin', 'checked': true }), 'Jellyfin'])
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; padding: 16px; border-top: 1px solid var(--kiss-line);' }, [
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'kiss-btn kiss-btn-green',
'click': function() {
var username = document.getElementById('new-username').value;
var password = document.getElementById('new-password').value;
var services = [];
if (document.getElementById('svc-nextcloud').checked) services.push('nextcloud');
if (document.getElementById('svc-peertube').checked) services.push('peertube');
if (document.getElementById('svc-jabber').checked) services.push('jabber');
if (document.getElementById('svc-matrix').checked) services.push('matrix');
if (document.getElementById('svc-email').checked) services.push('email');
if (document.getElementById('svc-gitea').checked) services.push('gitea');
if (document.getElementById('svc-jellyfin').checked) services.push('jellyfin');
if (!username) {
ui.addNotification(null, E('p', 'Username required'), 'error');
return;
}
ui.hideModal();
ui.showModal('Creating User...', [
E('p', { 'class': 'spinning' }, 'Please wait...')
]);
callAddUser(username, password, services.join(',')).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.showModal('User Created', [
E('div', { 'style': 'padding: 20px;' }, [
E('p', {}, 'User created successfully!'),
E('div', { 'style': 'margin: 16px 0; padding: 12px; background: var(--kiss-bg); border-radius: 6px;' }, [
E('div', { 'style': 'display: flex; gap: 12px; margin-bottom: 8px;' }, [
E('span', { 'style': 'color: var(--kiss-muted);' }, 'Username:'),
E('span', { 'style': 'font-weight: 600;' }, res.username)
]),
E('div', { 'style': 'display: flex; gap: 12px; margin-bottom: 8px;' }, [
E('span', { 'style': 'color: var(--kiss-muted);' }, 'Password:'),
E('span', { 'style': 'font-family: monospace; background: var(--kiss-bg2); padding: 4px 8px; border-radius: 4px;' }, res.password)
]),
E('div', { 'style': 'display: flex; gap: 12px;' }, [
E('span', { 'style': 'color: var(--kiss-muted);' }, 'Email:'),
E('span', {}, res.email)
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; padding: 16px;' }, [
E('button', { 'class': 'kiss-btn kiss-btn-green', 'click': function() { ui.hideModal(); location.reload(); } }, 'OK')
])
]);
} else {
ui.addNotification(null, E('p', 'Error: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Create User')
])
]);
},
handleDelete: function(username) {
if (!confirm('Delete user "' + username + '" from all services?')) {
return;
}
ui.showModal('Deleting...', [
E('p', { 'class': 'spinning' }, 'Please wait...')
]);
callDeleteUser(username).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', 'User deleted'), 'success');
location.reload();
} else {
ui.addNotification(null, E('p', 'Error: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handlePasswd: function(username) {
ui.showModal('Change Password', [
E('div', { 'style': 'padding: 16px;' }, [
E('p', {}, 'Change password for: ' + username),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 6px; margin-top: 16px;' }, [
E('label', { 'style': 'font-size: 12px; color: var(--kiss-muted);' }, 'New Password'),
E('input', {
'type': 'password',
'id': 'new-passwd',
'placeholder': 'Leave empty to generate',
'style': 'background: var(--kiss-bg); border: 1px solid var(--kiss-line); color: var(--kiss-text); padding: 10px 12px; border-radius: 6px;'
})
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; padding: 16px; border-top: 1px solid var(--kiss-line);' }, [
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'kiss-btn kiss-btn-blue',
'click': function() {
var password = document.getElementById('new-passwd').value;
ui.hideModal();
ui.showModal('Updating...', [
E('p', { 'class': 'spinning' }, 'Please wait...')
]);
callPasswd(username, password).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.showModal('Password Updated', [
E('div', { 'style': 'padding: 20px;' }, [
E('p', {}, 'Password updated for all services!'),
E('p', { 'style': 'font-family: monospace; background: var(--kiss-bg); padding: 12px; margin: 12px 0; border-radius: 6px;' }, res.password)
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; padding: 16px;' }, [
E('button', { 'class': 'kiss-btn kiss-btn-green', 'click': ui.hideModal }, 'OK')
])
]);
} else {
ui.addNotification(null, E('p', 'Error updating password'), 'error');
}
});
}
}, 'Update')
])
]);
},
render: function(data) {
var self = this;
var status = data[0] || {};
var usersData = data[1] || {};
var users = usersData.users || [];
var services = status.services || {};
// Start polling
poll.add(function() {
return callUsers().then(function(data) {
self.updateStats(data);
});
}, 30);
var totalLogins = 0;
var totalFailures = 0;
users.forEach(function(u) {
totalLogins += (u.login_success || 0);
totalFailures += (u.login_failure || 0);
});
var content = [
// Header
E('div', { 'style': 'margin-bottom: 24px;' }, [
E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [
E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'SecuBox User Management'),
KissTheme.badge(users.length + ' Users', 'blue')
E('h2', { 'style': 'margin: 0 0 8px 0; display: flex; align-items: center; gap: 12px;' }, [
E('span', {}, '\uD83D\uDC65'),
'SecuBox Users'
]),
E('p', { 'style': 'color: var(--kiss-muted); margin: 8px 0 0 0;' },
'Manage unified user accounts across all SecuBox services')
E('p', { 'style': 'color: var(--kiss-muted); margin: 0;' },
'Unified user management across all SecuBox services')
]),
// Stats
E('div', { 'class': 'kiss-grid kiss-grid-3', 'style': 'margin: 20px 0;' },
this.renderStats(status, users.length)),
// Stats Grid
E('div', { 'class': 'kiss-grid kiss-grid-4', 'style': 'margin-bottom: 20px;' }, [
this.statCard('Users', users.length, 'var(--kiss-cyan)', 'users'),
this.statCard('Services', 6, 'var(--kiss-purple)', 'services'),
this.statCard('Logins', totalLogins, 'var(--kiss-green)', 'logins'),
this.statCard('Failures', totalFailures,
totalFailures > 50 ? 'var(--kiss-red)' : 'var(--kiss-yellow)', 'failures')
]),
// Services
KissTheme.card('Available Services', this.renderServices(services)),
// Users
KissTheme.card(
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [
E('span', {}, 'Users'),
// Users Card
E('div', { 'class': 'kiss-card' }, [
E('div', { 'class': 'kiss-card-title', 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [
E('span', {}, '\uD83D\uDC65 User Accounts'),
E('button', {
'class': 'kiss-btn kiss-btn-green',
'style': 'padding: 6px 14px;',
'click': function() { self.handleAdd(); }
}, 'Add User')
'style': 'padding: 6px 12px; font-size: 12px;',
'click': ui.createHandlerFn(this, this.showAddUserModal)
}, '+ Add User')
]),
this.renderUsersTable(users)
)
this.renderUserTable(users)
]),
// Connected Services Card
E('div', { 'class': 'kiss-card kiss-panel-blue' }, [
E('div', { 'class': 'kiss-card-title' }, ['\uD83D\uDD17 Connected Services']),
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 16px;' }, [
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\u2601 Nextcloud'),
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\uD83C\uDFAC PeerTube'),
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\uD83D\uDCAC Jabber'),
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\uD83D\uDCE1 Matrix'),
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\u2709 Email'),
E('span', { 'class': 'kiss-badge kiss-badge-green' }, '\uD83D\uDCBB Gitea')
]),
E('p', { 'style': 'margin-top: 12px; color: var(--kiss-muted); font-size: 12px;' },
'Users are automatically provisioned across all enabled services.')
])
];
return KissTheme.wrap(content, 'admin/system/secubox-users/overview');
}
return KissTheme.wrap(content, 'admin/system/secubox-users');
},
statCard: function(label, value, color, dataAttr) {
return E('div', { 'class': 'kiss-stat' }, [
E('div', {
'class': 'kiss-stat-value',
'style': 'color: ' + color,
'data-stat': dataAttr
}, String(value)),
E('div', { 'class': 'kiss-stat-label' }, label)
]);
},
updateStats: function(data) {
var users = (data.users || []);
var usersEl = document.querySelector('[data-stat="users"]');
var loginsEl = document.querySelector('[data-stat="logins"]');
var failuresEl = document.querySelector('[data-stat="failures"]');
if (usersEl) usersEl.textContent = users.length;
var totalLogins = 0, totalFailures = 0;
users.forEach(function(u) {
totalLogins += (u.login_success || 0);
totalFailures += (u.login_failure || 0);
});
if (loginsEl) loginsEl.textContent = totalLogins;
if (failuresEl) {
failuresEl.textContent = totalFailures;
failuresEl.style.color = totalFailures > 50 ? 'var(--kiss-red)' : 'var(--kiss-yellow)';
}
},
renderUserTable: function(users) {
var self = this;
if (!users.length) {
return E('div', { 'style': 'text-align: center; padding: 40px; color: var(--kiss-muted);' }, [
E('p', { 'style': 'font-size: 48px; margin: 0;' }, '\uD83D\uDC65'),
E('p', {}, 'No users configured yet'),
E('button', {
'class': 'kiss-btn kiss-btn-green',
'click': ui.createHandlerFn(this, this.showAddUserModal)
}, '+ Add First User')
]);
}
var rows = users.map(function(user) {
var services = (user.services || []).map(function(s) {
return E('span', {
'style': 'display: inline-block; padding: 2px 8px; margin: 2px; border-radius: 4px; background: rgba(0,200,83,0.15); color: var(--kiss-green); font-size: 11px;'
}, s);
});
var failColor = (user.login_failure || 0) > 10 ? 'var(--kiss-red)' :
((user.login_failure || 0) > 0 ? 'var(--kiss-yellow)' : 'var(--kiss-green)');
return E('tr', {}, [
E('td', { 'style': 'padding: 12px 8px;' }, [
E('div', { 'style': 'font-weight: 600;' }, user.username),
E('div', { 'style': 'font-size: 12px; color: var(--kiss-muted);' }, user.email || '')
]),
E('td', { 'style': 'padding: 12px 8px; color: var(--kiss-muted);' }, user.last_login || 'Never'),
E('td', { 'style': 'padding: 12px 8px; text-align: center;' }, [
E('span', { 'style': 'color: var(--kiss-green); font-weight: 600;' }, String(user.login_success || 0)),
E('span', { 'style': 'color: var(--kiss-muted);' }, ' / '),
E('span', { 'style': 'color: ' + failColor + '; font-weight: 600;' }, String(user.login_failure || 0))
]),
E('td', { 'style': 'padding: 12px 8px;' }, services),
E('td', { 'style': 'padding: 12px 8px; text-align: right;' }, [
E('button', {
'class': 'kiss-btn',
'style': 'padding: 4px 10px; font-size: 12px; margin-right: 6px;',
'click': function() { self.showPasswdModal(user.username); }
}, '\uD83D\uDD11 Password'),
E('button', {
'class': 'kiss-btn kiss-btn-red',
'style': 'padding: 4px 10px; font-size: 12px;',
'click': function() { self.showDeleteModal(user.username); }
}, '\uD83D\uDDD1 Delete')
])
]);
});
return E('table', { 'style': 'width: 100%; border-collapse: collapse;' }, [
E('thead', {}, [
E('tr', { 'style': 'border-bottom: 1px solid var(--kiss-border);' }, [
E('th', { 'style': 'padding: 8px; text-align: left; color: var(--kiss-muted); font-size: 11px; text-transform: uppercase;' }, 'User'),
E('th', { 'style': 'padding: 8px; text-align: left; color: var(--kiss-muted); font-size: 11px; text-transform: uppercase;' }, 'Last Login'),
E('th', { 'style': 'padding: 8px; text-align: center; color: var(--kiss-muted); font-size: 11px; text-transform: uppercase;' }, 'Success / Fail'),
E('th', { 'style': 'padding: 8px; text-align: left; color: var(--kiss-muted); font-size: 11px; text-transform: uppercase;' }, 'Services'),
E('th', { 'style': 'padding: 8px; text-align: right; color: var(--kiss-muted); font-size: 11px; text-transform: uppercase;' }, 'Actions')
])
]),
E('tbody', {}, rows)
]);
},
showAddUserModal: function() {
var self = this;
ui.showModal(_('Add User'), [
E('div', { 'style': 'min-width: 400px;' }, [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Username'),
E('input', {
'type': 'text',
'id': 'new-username',
'placeholder': 'johndoe',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Password'),
E('input', {
'type': 'password',
'id': 'new-password',
'placeholder': 'Leave empty to auto-generate',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Services'),
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 12px;' }, [
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-nextcloud', 'checked': true }), 'Nextcloud'
]),
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-peertube', 'checked': true }), 'PeerTube'
]),
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-jabber', 'checked': true }), 'Jabber'
]),
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-matrix', 'checked': true }), 'Matrix'
]),
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-email', 'checked': true }), 'Email'
]),
E('label', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'svc-gitea', 'checked': true }), 'Gitea'
])
])
]),
E('div', { 'style': 'display: flex; gap: 12px; justify-content: flex-end;' }, [
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'kiss-btn kiss-btn-green',
'click': function() { self.doAddUser(); }
}, '\u2714 Create User')
])
])
]);
},
doAddUser: function() {
var username = document.getElementById('new-username').value.trim();
var password = document.getElementById('new-password').value;
if (!username) {
ui.addNotification(null, E('p', _('Username required')), 'warning');
return;
}
var services = [];
['nextcloud', 'peertube', 'jabber', 'matrix', 'email', 'gitea'].forEach(function(s) {
if (document.getElementById('svc-' + s).checked) services.push(s);
});
ui.hideModal();
callAddUser(username, password, services.join(',')).then(function(res) {
if (res.success) {
var msg = 'User ' + username + ' created';
if (res.password) msg += '. Password: ' + res.password;
ui.addNotification(null, E('p', msg), 'info');
window.location.reload();
} else {
ui.addNotification(null, E('p', res.error || 'Failed'), 'error');
}
});
},
showPasswdModal: function(username) {
var self = this;
ui.showModal(_('Change Password'), [
E('div', { 'style': 'min-width: 350px;' }, [
E('p', {}, 'Change password for: ' + E('strong', {}, username)),
E('div', { 'style': 'margin: 16px 0;' }, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'New Password'),
E('input', {
'type': 'password',
'id': 'change-password',
'placeholder': 'Leave empty to auto-generate',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
E('div', { 'style': 'display: flex; gap: 12px; justify-content: flex-end;' }, [
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'kiss-btn kiss-btn-blue',
'click': function() { self.doPasswd(username); }
}, '\uD83D\uDD11 Change')
])
])
]);
},
doPasswd: function(username) {
var password = document.getElementById('change-password').value;
ui.hideModal();
callPasswd(username, password).then(function(res) {
if (res.success) {
var msg = 'Password changed for ' + username;
if (res.password) msg += '. New: ' + res.password;
ui.addNotification(null, E('p', msg), 'info');
} else {
ui.addNotification(null, E('p', res.error || 'Failed'), 'error');
}
});
},
showDeleteModal: function(username) {
var self = this;
ui.showModal(_('Delete User'), [
E('div', { 'style': 'min-width: 300px;' }, [
E('p', { 'style': 'color: var(--kiss-red);' },
'Delete user: ' + E('strong', {}, username) + '?'),
E('p', { 'style': 'color: var(--kiss-muted); font-size: 13px;' },
'This removes the user from all services.'),
E('div', { 'style': 'display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px;' }, [
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'kiss-btn kiss-btn-red',
'click': function() { self.doDelete(username); }
}, '\uD83D\uDDD1 Delete')
])
])
]);
},
doDelete: function(username) {
ui.hideModal();
callDeleteUser(username).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', 'User ' + username + ' deleted'), 'info');
window.location.reload();
} else {
ui.addNotification(null, E('p', res.error || 'Failed'), 'error');
}
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -1,9 +1,10 @@
'use strict';
'require view';
'require form';
'require ui';
'require uci';
'require rpc';
'require ui';
'require poll';
'require uci';
'require secubox/kiss-theme';
var callGetStatus = rpc.declare({
object: 'luci.smtp-relay',
@ -11,12 +12,6 @@ var callGetStatus = rpc.declare({
expect: {}
});
var callGetConfig = rpc.declare({
object: 'luci.smtp-relay',
method: 'get_config',
expect: {}
});
var callTestEmail = rpc.declare({
object: 'luci.smtp-relay',
method: 'test_email',
@ -34,248 +29,319 @@ return view.extend({
load: function() {
return Promise.all([
callGetStatus().catch(function() { return {}; }),
callGetConfig().catch(function() { return {}; }),
callDetectLocal().catch(function() { return {}; }),
uci.load('smtp-relay')
]);
},
handleTestEmail: function(ev) {
var recipient = document.getElementById('test_recipient').value;
var btn = ev.target;
var resultDiv = document.getElementById('test_result');
render: function(data) {
var self = this;
var status = data[0] || {};
if (!recipient) {
recipient = uci.get('smtp-relay', 'recipients', 'admin') || '';
// Start polling
poll.add(function() {
return callGetStatus().then(function(s) {
self.updateStats(s);
});
}, 15);
var modeLabels = {
'external': 'External',
'local': 'Local',
'direct': 'Direct'
};
var content = [
// Header
E('div', { 'style': 'margin-bottom: 24px;' }, [
E('h2', { 'style': 'margin: 0 0 8px 0; display: flex; align-items: center; gap: 12px;' }, [
E('span', {}, '\u2709'),
'SMTP Relay'
]),
E('p', { 'style': 'color: var(--kiss-muted); margin: 0;' },
'Centralized outbound email for all SecuBox services')
]),
// Stats Grid
E('div', { 'class': 'kiss-grid kiss-grid-4', 'style': 'margin-bottom: 20px;' }, [
this.statCard('Status', status.enabled ? 'Active' : 'Disabled',
status.enabled ? 'var(--kiss-green)' : 'var(--kiss-red)', 'status'),
this.statCard('Mode', modeLabels[status.mode] || 'N/A',
status.mode === 'local' ? 'var(--kiss-green)' : 'var(--kiss-cyan)', 'mode'),
this.statCard('Server', status.server || 'None', 'var(--kiss-purple)', 'server'),
this.statCard('TLS', status.tls ? 'Enabled' : 'Off',
status.tls ? 'var(--kiss-green)' : 'var(--kiss-yellow)', 'tls')
]),
// Quick Test Card
E('div', { 'class': 'kiss-card' }, [
E('div', { 'class': 'kiss-card-title' }, ['\u26a1 Quick Actions']),
E('div', { 'style': 'display: flex; gap: 12px; flex-wrap: wrap; align-items: center;' }, [
E('button', {
'class': 'kiss-btn kiss-btn-blue',
'click': ui.createHandlerFn(this, this.doDetectLocal)
}, '\ud83d\udd0d Detect Local'),
E('input', {
'type': 'email',
'id': 'test_recipient',
'placeholder': 'test@example.com',
'value': status.admin_recipient || '',
'style': 'flex: 1; min-width: 200px; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
}),
E('button', {
'id': 'btn-test',
'class': 'kiss-btn kiss-btn-green',
'click': ui.createHandlerFn(this, this.doTestEmail)
}, '\u2709 Send Test'),
E('span', { 'id': 'test_result' }, '')
])
]),
// Configuration Card
E('div', { 'class': 'kiss-card' }, [
E('div', { 'class': 'kiss-card-title' }, ['\u2699 Configuration']),
E('div', { 'style': 'display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;' }, [
// Mode
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Relay Mode'),
E('select', {
'id': 'cfg_mode',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
}, [
E('option', { 'value': 'external', 'selected': status.mode === 'external' }, 'External SMTP'),
E('option', { 'value': 'local', 'selected': status.mode === 'local' }, 'Local Mailserver'),
E('option', { 'value': 'direct', 'selected': status.mode === 'direct' }, 'Direct Delivery')
])
]),
// Server
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'SMTP Server'),
E('input', {
'type': 'text',
'id': 'cfg_server',
'placeholder': 'smtp.gmail.com',
'value': status.server || '',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
// Port
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Port'),
E('input', {
'type': 'number',
'id': 'cfg_port',
'placeholder': '587',
'value': status.port || 587,
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
// TLS
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Use TLS'),
E('div', { 'style': 'display: flex; align-items: center; gap: 8px;' }, [
E('input', {
'type': 'checkbox',
'id': 'cfg_tls',
'checked': status.tls,
'style': 'width: 18px; height: 18px;'
}),
E('span', { 'style': 'color: var(--kiss-muted);' }, 'Enable STARTTLS')
])
]),
// Username
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Username'),
E('input', {
'type': 'text',
'id': 'cfg_user',
'placeholder': 'user@gmail.com',
'value': uci.get('smtp-relay', 'main', 'username') || '',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
// Password
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Password'),
E('input', {
'type': 'password',
'id': 'cfg_pass',
'placeholder': '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
'value': '',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
// From Address
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'From Address'),
E('input', {
'type': 'email',
'id': 'cfg_from',
'placeholder': 'noreply@secubox.in',
'value': status.from || '',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
]),
// Admin Recipient
E('div', {}, [
E('div', { 'style': 'color: var(--kiss-muted); font-size: 11px; text-transform: uppercase; margin-bottom: 6px;' }, 'Admin Email'),
E('input', {
'type': 'email',
'id': 'cfg_admin',
'placeholder': 'admin@example.com',
'value': status.admin_recipient || '',
'style': 'width: 100%; padding: 8px 12px; border: 1px solid var(--kiss-border); border-radius: 6px; background: var(--kiss-bg2); color: var(--kiss-text);'
})
])
]),
E('div', { 'style': 'margin-top: 20px;' }, [
E('button', {
'class': 'kiss-btn kiss-btn-green',
'click': ui.createHandlerFn(this, this.doSaveConfig)
}, '\u2714 Save Configuration')
])
]),
// Quick Presets Card
E('div', { 'class': 'kiss-card kiss-panel-blue' }, [
E('div', { 'class': 'kiss-card-title' }, ['\ud83d\udce7 Provider Presets']),
E('div', { 'style': 'display: flex; gap: 12px; flex-wrap: wrap;' }, [
this.presetButton('Gmail', 'smtp.gmail.com', 587, true),
this.presetButton('SendGrid', 'smtp.sendgrid.net', 587, true),
this.presetButton('Mailgun', 'smtp.mailgun.org', 587, true),
this.presetButton('AWS SES', 'email-smtp.us-east-1.amazonaws.com', 587, true),
this.presetButton('Local', '192.168.255.30', 25, false)
]),
E('p', { 'style': 'margin-top: 12px; color: var(--kiss-muted); font-size: 12px;' },
'Click a preset to auto-fill server settings.')
])
];
return KissTheme.wrap(content, 'admin/secubox/system/smtp-relay');
},
statCard: function(label, value, color, dataAttr) {
return E('div', { 'class': 'kiss-stat' }, [
E('div', {
'class': 'kiss-stat-value',
'style': 'color: ' + color,
'data-stat': dataAttr
}, String(value)),
E('div', { 'class': 'kiss-stat-label' }, label)
]);
},
updateStats: function(status) {
var statusEl = document.querySelector('[data-stat="status"]');
var modeEl = document.querySelector('[data-stat="mode"]');
var serverEl = document.querySelector('[data-stat="server"]');
var tlsEl = document.querySelector('[data-stat="tls"]');
if (statusEl) {
statusEl.textContent = status.enabled ? 'Active' : 'Disabled';
statusEl.style.color = status.enabled ? 'var(--kiss-green)' : 'var(--kiss-red)';
}
if (modeEl) {
var labels = { 'external': 'External', 'local': 'Local', 'direct': 'Direct' };
modeEl.textContent = labels[status.mode] || 'N/A';
}
if (serverEl) {
serverEl.textContent = status.server || 'None';
}
if (tlsEl) {
tlsEl.textContent = status.tls ? 'Enabled' : 'Off';
tlsEl.style.color = status.tls ? 'var(--kiss-green)' : 'var(--kiss-yellow)';
}
},
presetButton: function(name, server, port, tls) {
return E('button', {
'class': 'kiss-btn',
'click': function() {
document.getElementById('cfg_server').value = server;
document.getElementById('cfg_port').value = port;
document.getElementById('cfg_tls').checked = tls;
document.getElementById('cfg_mode').value = name === 'Local' ? 'local' : 'external';
}
}, name);
},
doDetectLocal: function() {
ui.showModal(_('Detecting Local Mailserver'), [
E('p', { 'class': 'spinning' }, _('Scanning...'))
]);
callDetectLocal().then(function(res) {
ui.hideModal();
if (res.detected) {
document.getElementById('cfg_server').value = res.server || '192.168.255.30';
document.getElementById('cfg_port').value = res.port || 25;
document.getElementById('cfg_mode').value = 'local';
ui.addNotification(null, E('p', _('Local mailserver detected')), 'info');
} else {
ui.addNotification(null, E('p', _('No local mailserver found')), 'warning');
}
});
},
doTestEmail: function() {
var recipient = document.getElementById('test_recipient').value;
var btn = document.getElementById('btn-test');
var result = document.getElementById('test_result');
if (!recipient) {
ui.addNotification(null, E('p', _('Please enter a recipient email address')), 'warning');
ui.addNotification(null, E('p', _('Enter a recipient email')), 'warning');
return;
}
btn.disabled = true;
btn.textContent = _('Sending...');
resultDiv.innerHTML = '';
btn.textContent = 'Sending...';
result.innerHTML = '';
callTestEmail(recipient).then(function(res) {
btn.disabled = false;
btn.textContent = _('Send Test Email');
btn.textContent = '\u2709 Send Test';
if (res.success) {
resultDiv.innerHTML = '<span style="color: #4caf50;">✓ ' + _('Test email sent successfully!') + '</span>';
ui.addNotification(null, E('p', _('Test email sent to %s').format(recipient)), 'info');
result.innerHTML = '<span class="kiss-badge kiss-badge-green">Sent</span>';
} else {
resultDiv.innerHTML = '<span style="color: #f44336;">✗ ' + (res.error || _('Failed to send')) + '</span>';
ui.addNotification(null, E('p', res.error || _('Failed to send test email')), 'error');
result.innerHTML = '<span class="kiss-badge kiss-badge-red">Failed</span>';
ui.addNotification(null, E('p', res.error || 'Send failed'), 'error');
}
}).catch(function(err) {
btn.disabled = false;
btn.textContent = _('Send Test Email');
resultDiv.innerHTML = '<span style="color: #f44336;">✗ ' + err.message + '</span>';
btn.textContent = '\u2709 Send Test';
result.innerHTML = '<span class="kiss-badge kiss-badge-red">Error</span>';
});
},
render: function(data) {
var status = data[0] || {};
var config = data[1] || {};
var localDetect = data[2] || {};
var m, s, o;
doSaveConfig: function() {
var mode = document.getElementById('cfg_mode').value;
var server = document.getElementById('cfg_server').value;
var port = document.getElementById('cfg_port').value;
var user = document.getElementById('cfg_user').value;
var pass = document.getElementById('cfg_pass').value;
var tls = document.getElementById('cfg_tls').checked;
var from = document.getElementById('cfg_from').value;
var admin = document.getElementById('cfg_admin').value;
m = new form.Map('smtp-relay', _('SMTP Relay'),
_('Centralized outbound email configuration for all SecuBox services. Configure once, use everywhere.'));
uci.set('smtp-relay', 'main', 'mode', mode);
uci.set('smtp-relay', 'main', 'server', server);
uci.set('smtp-relay', 'main', 'port', port);
if (user) uci.set('smtp-relay', 'main', 'username', user);
if (pass) uci.set('smtp-relay', 'main', 'password', pass);
uci.set('smtp-relay', 'main', 'tls', tls ? '1' : '0');
if (from) uci.set('smtp-relay', 'main', 'from', from);
uci.set('smtp-relay', 'recipients', 'admin', admin);
// Status section
s = m.section(form.NamedSection, 'main', 'smtp_relay', _('Status'));
s.anonymous = true;
o = s.option(form.DummyValue, '_status', _('Current Status'));
o.rawhtml = true;
o.cfgvalue = function() {
var modeText = {
'external': _('External SMTP Server'),
'local': _('Local Mailserver'),
'direct': _('Direct Delivery')
};
var statusHtml = '<div style="display: flex; gap: 20px; flex-wrap: wrap;">';
// Enabled status
statusHtml += '<div><strong>' + _('Relay:') + '</strong> ';
if (status.enabled) {
statusHtml += '<span style="color: #4caf50;">●</span> ' + _('Enabled');
} else {
statusHtml += '<span style="color: #f44336;">●</span> ' + _('Disabled');
}
statusHtml += '</div>';
// Mode
statusHtml += '<div><strong>' + _('Mode:') + '</strong> ' + (modeText[status.mode] || status.mode || '-') + '</div>';
// Server
if (status.server) {
statusHtml += '<div><strong>' + _('Server:') + '</strong> ' + status.server + ':' + status.port + '</div>';
}
// Transport
statusHtml += '<div><strong>' + _('Transport:') + '</strong> ';
if (status.msmtp_available) {
statusHtml += '<span style="color: #4caf50;">msmtp</span>';
} else if (status.sendmail_available) {
statusHtml += '<span style="color: #ff9800;">sendmail</span>';
} else {
statusHtml += '<span style="color: #f44336;">' + _('None') + '</span>';
}
statusHtml += '</div>';
// Local mailserver
if (localDetect.detected) {
statusHtml += '<div><strong>' + _('Local Mail:') + '</strong> ';
if (localDetect.responding) {
statusHtml += '<span style="color: #4caf50;">● ' + _('Available') + '</span>';
} else {
statusHtml += '<span style="color: #ff9800;">● ' + _('Not responding') + '</span>';
}
statusHtml += '</div>';
}
statusHtml += '</div>';
return statusHtml;
};
// Main settings
s = m.section(form.NamedSection, 'main', 'smtp_relay', _('General Settings'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enable SMTP Relay'));
o.default = '0';
o.rmempty = false;
o = s.option(form.ListValue, 'mode', _('Relay Mode'));
o.value('external', _('External SMTP Server'));
o.value('local', _('Local Mailserver'));
o.value('direct', _('Direct Delivery (MTA)'));
o.default = 'external';
o.description = _('External: Use Gmail, SendGrid, etc. Local: Use secubox-app-mailserver. Direct: Send directly (requires port 25).');
o = s.option(form.Flag, 'auto_detect', _('Auto-detect Local Mailserver'));
o.description = _('Automatically use local mailserver if available and responding');
o.default = '1';
o.depends('mode', 'external');
// External SMTP section
s = m.section(form.NamedSection, 'external', 'external', _('External SMTP Settings'));
s.anonymous = true;
o = s.option(form.ListValue, '_preset', _('Provider Preset'));
o.value('', _('-- Custom --'));
o.value('gmail', 'Gmail / Google Workspace');
o.value('sendgrid', 'SendGrid');
o.value('mailgun', 'Mailgun');
o.value('ses', 'Amazon SES (us-east-1)');
o.value('mailjet', 'Mailjet');
o.rmempty = true;
o.write = function() {}; // Don't save, just triggers onchange
o = s.option(form.Value, 'server', _('SMTP Server'));
o.placeholder = 'smtp.example.com';
o.rmempty = true;
o = s.option(form.Value, 'port', _('Port'));
o.datatype = 'port';
o.default = '587';
o.placeholder = '587';
o = s.option(form.Flag, 'tls', _('Use STARTTLS'));
o.default = '1';
o.description = _('Use STARTTLS encryption (recommended for port 587)');
o = s.option(form.Flag, 'ssl', _('Use SSL/TLS'));
o.default = '0';
o.description = _('Use implicit SSL/TLS (for port 465)');
o = s.option(form.Flag, 'auth', _('Authentication Required'));
o.default = '1';
o = s.option(form.Value, 'user', _('Username'));
o.depends('auth', '1');
o.rmempty = true;
o = s.option(form.Value, 'password', _('Password'));
o.password = true;
o.depends('auth', '1');
o.rmempty = true;
o = s.option(form.Value, 'from', _('From Address'));
o.placeholder = 'secubox@example.com';
o.datatype = 'email';
o.rmempty = true;
o = s.option(form.Value, 'from_name', _('From Name'));
o.placeholder = 'SecuBox';
o.default = 'SecuBox';
o.rmempty = true;
// Recipients section
s = m.section(form.NamedSection, 'recipients', 'recipients', _('Default Recipients'));
s.anonymous = true;
o = s.option(form.Value, 'admin', _('Admin Email'));
o.datatype = 'email';
o.description = _('Default recipient for system notifications and test emails');
// Test section
s = m.section(form.NamedSection, 'main', 'smtp_relay', _('Connection Test'));
s.anonymous = true;
o = s.option(form.DummyValue, '_test', ' ');
o.rawhtml = true;
o.cfgvalue = function() {
var adminEmail = uci.get('smtp-relay', 'recipients', 'admin') || '';
return E('div', { 'style': 'display: flex; gap: 10px; align-items: center; flex-wrap: wrap;' }, [
E('input', {
'type': 'email',
'id': 'test_recipient',
'placeholder': adminEmail || _('recipient@example.com'),
'value': adminEmail,
'style': 'flex: 1; min-width: 200px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;'
}),
E('button', {
'class': 'cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, function(ev) {
var view = document.querySelector('[data-page="smtp-relay/settings"]');
if (view && view.handleTestEmail) {
view.handleTestEmail(ev);
} else {
// Fallback
var recipient = document.getElementById('test_recipient').value;
var btn = ev.target;
btn.disabled = true;
btn.textContent = _('Sending...');
callTestEmail(recipient).then(function(res) {
btn.disabled = false;
btn.textContent = _('Send Test Email');
var resultDiv = document.getElementById('test_result');
if (res.success) {
resultDiv.innerHTML = '<span style="color: #4caf50;">✓ ' + _('Test email sent!') + '</span>';
} else {
resultDiv.innerHTML = '<span style="color: #f44336;">✗ ' + (res.error || _('Failed')) + '</span>';
}
}).catch(function(err) {
btn.disabled = false;
btn.textContent = _('Send Test Email');
});
}
})
}, _('Send Test Email')),
E('div', { 'id': 'test_result', 'style': 'margin-left: 10px;' })
]);
};
return m.render();
uci.save().then(function() {
return uci.apply();
}).then(function() {
ui.addNotification(null, E('p', _('Configuration saved')), 'info');
}).catch(function(err) {
ui.addNotification(null, E('p', err.message), 'error');
});
},
handleSave: null,
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -203,7 +203,9 @@ var KissThemeClass = baseclass.extend({
{ cat: 'Downloads', icon: '📥', collapsed: true, items: [
{ icon: '🧲', name: 'Torrent', path: 'admin/services/torrent' },
{ icon: '💧', name: 'Droplet', path: 'admin/services/droplet' },
{ icon: '🌊', name: 'WebTorrent', path: 'admin/services/webtorrent' }
{ icon: '🌊', name: 'WebTorrent', path: 'admin/services/webtorrent' },
{ icon: '📰', name: 'SABnzbd', url: 'https://sabnzbd.gk2.secubox.in/' },
{ icon: '🔍', name: 'NZBHydra', url: 'https://nzbhydra.gk2.secubox.in/' }
]},
// ═══════════════════════════════════════════════════════════════