fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 5s

This commit is contained in:
2026-08-03 13:54:53 -04:00
parent 8143ef8ca8
commit 70b76c6ed5
10 changed files with 450 additions and 284 deletions
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.20.0", "version": "1.20.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.20.0", "version": "1.20.1",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+1 -1
View File
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {}; const meta = resource.metadata || {};
meta.managed = true; meta.managed = true;
await resource.update({ metadata: meta }); await Resource.update(resource.id, { metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); } } catch (err) { next(err); }
+1 -8
View File
@@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) {
}); });
router.get('/plugins', function(req, res, next) { router.get('/plugins', function(req, res, next) {
// Plugin instances page — loadable/unloadable, configurable plugin copies res.redirect('/directory');
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
// client gates with app.auth.forceLogin(['app_sso_admin',
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie).
const registry = require('../services/plugin_registry');
res.render('plugins', {...values, pluginTypes: registry.types });
}); });
router.get('/vault', function(req, res) { router.get('/vault', function(req, res) {
+24 -14
View File
@@ -12,32 +12,39 @@ class DiscoveryReconciler {
res._originalSlug = res.slug; // Keep track for edge mapping res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null; let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
if (macs.length > 0) {
const allRes = await Resource.list(); const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r => existing = allRes.find(r =>
r.metadata && r.metadata.interfaces && r.metadata && (
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase())) (r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
); );
} }
} }
// Fallback matching by IP if no MAC match (weaker) // 2. Fallback matching by IP address
let ipsToMatch = []; let ipsToMatch = [];
if (res.metadata.interfaces) { if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i); ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
} }
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) { if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim())); res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
} }
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) { if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => { existing = allRes.find(r => {
if (!r.metadata) return false; if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) { if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim()); const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true; if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -47,13 +54,16 @@ class DiscoveryReconciler {
}); });
} }
// Fallback matching by Slug or Name // 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) { if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list(); const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => existing = allRes.find(r => {
(res.slug && r.slug === res.slug) || if (res.slug && r.slug === res.slug) return true;
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
); if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
} }
if (existing) { if (existing) {
-1
View File
@@ -44,7 +44,6 @@ module.exports = {
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*. // Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
+2 -1
View File
@@ -156,11 +156,12 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them. // client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin'; const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) { async function isAdmin(user) {
try { try {
await permission.byGroup(user, [ADMIN_GROUP]); await permission.byGroup(user, ADMIN_GROUPS);
return true; return true;
} catch (e) { } catch (e) {
return false; return false;
+256 -183
View File
@@ -2,10 +2,14 @@
<script type="text/javascript"> <script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']); app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() { $(document).ready(function() {
loadConf(); loadConf();
loadProxyConf(); loadProxyConf();
loadTos(); loadTos();
loadMessagingPlugins();
}); });
async function loadConf() { async function loadConf() {
@@ -76,7 +80,7 @@
} catch (error) { } catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
} }
} }
@@ -87,13 +91,11 @@
return; return;
} }
const $inputGroup = $('#test-email-to').closest('.input-group'); const btn = $('#btn-test-email');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try { try {
// First save the SMTP config, then send test email
const payload = { const payload = {
smtp: { smtp: {
host: $('#smtp-host').val(), host: $('#smtp-host').val(),
@@ -104,11 +106,7 @@
secure: $('#smtp-secure').is(':checked') secure: $('#smtp-secure').is(':checked')
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test email
const result = await app.api.post('conf/test-email', { to }); const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success'); app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val(''); $('#test-email-to').val('');
@@ -126,13 +124,11 @@
return; return;
} }
const $inputGroup = $('#test-sms-to').closest('.input-group'); const btn = $('#btn-test-sms');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try { try {
// First save the VoIP.ms config, then send test SMS
const payload = { const payload = {
voipms: { voipms: {
username: $('#voipms-username').val(), username: $('#voipms-username').val(),
@@ -140,11 +136,7 @@
password: $('#voipms-password').val() password: $('#voipms-password').val()
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test SMS
const result = await app.api.post('conf/test-sms', { to }); const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success'); app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val(''); $('#test-sms-to').val('');
@@ -201,22 +193,18 @@
} catch (error) { } catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
} }
} }
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadTos() { async function loadTos() {
try { try {
const tos = await app.tos.get(); const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content; document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent = document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by; 'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) { } catch(e) {
console.error('Failed to load ToS:', e); console.error('Failed to load ToS:', e);
} }
@@ -248,249 +236,334 @@
loadTos(); loadTos();
}); });
} }
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-3"><i class="fas fa-plug text-secondary mb-2 fs-3"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
</script> </script>
<div class="container py-4"> <div class="container-fluid py-4 px-md-4">
<div class="row mb-4"> <!-- Page Header -->
<div class="col d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
<div> <div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <h3 class="fw-bold mb-1"><i class="fas fa-sliders-h text-primary me-2"></i> System Configuration</h3>
<p class="text-muted mb-0"> <p class="text-muted mb-0 small">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao.
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p> </p>
</div> </div>
<div> <div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button> <button class="btn btn-outline-secondary me-2" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button> <button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-lg-3 col-md-4">
<div class="card shadow-sm border-0 sticky-top" style="top: var(--sw-content-offset, 70px);">
<div class="list-group list-group-flush rounded-3" id="conf-nav-list" role="tablist">
<a class="list-group-item list-group-item-action active d-flex align-items-center py-3" id="tab-nav-oauth" data-bs-toggle="list" href="#pane-oauth" role="tab">
<i class="fas fa-key text-success me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">OAuth & JWT</div>
<div class="small text-muted">Issuer & Token Lifetimes</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-smtp" data-bs-toggle="list" href="#pane-smtp" role="tab">
<i class="fas fa-envelope text-primary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Email (SMTP)</div>
<div class="small text-muted">Mail Delivery & Testing</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-sms" data-bs-toggle="list" href="#pane-sms" role="tab">
<i class="fas fa-comment-sms text-info me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">SMS & Messaging</div>
<div class="small text-muted">VoIP.ms & Webhook Plugins</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-proxy" data-bs-toggle="list" href="#pane-proxy" role="tab">
<i class="fas fa-shield-alt text-warning me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Proxy Secrets</div>
<div class="small text-muted">OpenBao Integration</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-tos" data-bs-toggle="list" href="#pane-tos" role="tab">
<i class="fas fa-file-contract text-secondary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Terms of Service</div>
<div class="small text-muted">User Agreement & Policy</div>
</div>
</a>
</div> </div>
</div> </div>
</div> </div>
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist"> <!-- Main Content Panes -->
<li class="nav-item" role="presentation"> <div class="col-lg-9 col-md-8">
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button> <div class="tab-content" id="conf-tab-content">
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
</li>
</ul>
<div class="tab-content" id="confTabsContent"> <!-- OAuth Pane -->
<!-- SMTP Tab --> <div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Host</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="smtp-host"> <input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Port</label> <label class="form-label fw-semibold">JWT Secret</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
</div>
</div>
</div>
</div>
<!-- OAuth Tab -->
<div class="tab-pane fade" id="oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********"> <input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div> <div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Access Token Lifetime (seconds)</label> <div class="col-md-6 mb-3">
<input type="number" class="form-control" id="oauth-token-access"> <label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div> </div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- SMS Tab --> <!-- SMTP Pane -->
<div class="tab-pane fade" id="sms" role="tabpanel"> <div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
</div>
<div class="card-body p-4">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div> </div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Username</label> <label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
</div>
</div>
<!-- SMS & Messaging Pane -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
</div>
<div class="card-body p-4">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username"> <input type="text" class="form-control" id="voipms-username">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">DID (sender number)</label> <label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567"> <input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div> </div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Password</label> <label class="form-label fw-semibold">API Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********"> <input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div> </div>
<hr class="my-4">
<div class="mb-3"> <div class="p-3 bg-light rounded border mb-4">
<label class="form-label">Send Test SMS</label> <h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group"> <div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567"> <input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()"> <button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS <i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button> </button>
</div> </div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div> </div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div id="messaging-plugins-list"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Proxy Secrets Tab --> <!-- Proxy Secrets Pane -->
<div class="tab-pane fade" id="proxy" role="tabpanel"> <div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p> <p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6> <h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Issuer URL</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com"> <input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Client ID</label> <div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id"> <input type="text" class="form-control" id="proxy-client-id">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Client Secret</label> <label class="form-label fw-semibold">Client Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********"> <input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div> </div>
</div> </div>
</div>
<h6 class="mt-4 mb-2">LDAP Integration</h6> <h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Bind Password</label> <label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********"> <input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Password for the Proxy's LDAP service account.</div>
</div> </div>
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button> <button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div> </div>
</div> </div>
</div> </div>
<!-- ToS Tab --> <!-- Terms of Service Pane -->
<div class="tab-pane fade" id="tos" role="tabpanel"> <div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center"> <div class="card-header bg-white pt-4 pb-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<small class="text-muted" id="tos-meta"></small> <span class="small text-muted" id="tos-meta"></span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label> <label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control" id="tos-content" rows="8"></textarea> <textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance"> <input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label> <label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div> </div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button> <button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-2"></div> <div id="tos-result" style="display:none" class="mt-3"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
+95 -5
View File
@@ -13,7 +13,12 @@
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false"> <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
<i class="fa-solid fa-network-wired"></i> Discovery <i class="fa-solid fa-network-wired"></i> Discovered Inventory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
<i class="fa-solid fa-plug"></i> Discovery Plugins
</button> </button>
</li> </li>
</ul> </ul>
@@ -187,6 +192,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Discovery Plugins Tab Pane -->
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
<div class="p-4 bg-white border-top">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1188,6 +1207,7 @@
allEdges.push(res.results); allEdges.push(res.results);
refreshEdgesUI(resourceId); refreshEdgesUI(resourceId);
$('#new-edge-target').val(''); $('#new-edge-target').val('');
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger'); app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1199,6 +1219,7 @@
await app.api.delete('directory-admin/edges/' + id); await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id); allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val()); refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1493,7 +1514,7 @@
`; `;
app.modal.open({ app.modal.open({
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent', title: 'Install Theta Agent',
bodyHtml: bodyHtml, bodyHtml: bodyHtml,
size: 'lg' size: 'lg'
}); });
@@ -1501,12 +1522,81 @@
updateAgentCommands(); updateAgentCommands();
} }
// Plugin scheduling moved to the dedicated /plugins page (the Agents & var discoveryPlugins = [];
// Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page. function loadDiscoveryPlugins() {
app.api.get('plugins', function(err, res) {
if (err) return;
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
renderDiscoveryPlugins();
});
}
function renderDiscoveryPlugins() {
const $list = $('#discovery-plugins-list').empty();
if (discoveryPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
return;
}
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function runDiscoveryPluginNow(id) {
try {
await app.api.post(`plugins/${id}/run`, {});
app.messages.toast('Enqueued discovery plugin run', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error running plugin: ' + e.message, 'danger');
}
}
async function deleteDiscoveryPlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Discovery plugin deleted', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
$(document).ready(function(){ $(document).ready(function(){
loadDiscoveryResources(); loadDiscoveryResources();
loadDiscoveryPlugins();
}); });
</script> </script>