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:
parent
ee7cd8ef6f
commit
c74ba2e474
@ -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"
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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/' }
|
||||
]},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Loading…
Reference in New Issue
Block a user