Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9fb80c3b2 |
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.2",
|
||||
"version": "1.19.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.2",
|
||||
"version": "1.19.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.2",
|
||||
"version": "1.19.6",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
|
||||
|
||||
const meta = resource.metadata || {};
|
||||
meta.managed = true;
|
||||
await Resource.update(resource.id, { metadata: meta });
|
||||
await resource.update({ metadata: meta });
|
||||
|
||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -84,7 +84,14 @@ router.get('/discovery', function(req, res, next) {
|
||||
});
|
||||
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
||||
// 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) {
|
||||
|
||||
@@ -12,39 +12,32 @@ class DiscoveryReconciler {
|
||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||
|
||||
let existing = null;
|
||||
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||
|
||||
const allRes = await Resource.list();
|
||||
|
||||
// 1. Attempt matching by MAC (highest precision)
|
||||
|
||||
// Attempt matching by MAC if available (case-insensitive)
|
||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||
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();
|
||||
existing = allRes.find(r =>
|
||||
r.metadata && (
|
||||
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
||||
)
|
||||
r.metadata && r.metadata.interfaces &&
|
||||
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback matching by IP address
|
||||
|
||||
// Fallback matching by IP if no MAC match (weaker)
|
||||
let ipsToMatch = [];
|
||||
if (res.metadata.interfaces) {
|
||||
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||
}
|
||||
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||
if (res.metadata.address) {
|
||||
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||
}
|
||||
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||
|
||||
|
||||
if (!existing && ipsToMatch.length > 0) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r => {
|
||||
if (!r.metadata) return false;
|
||||
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||
if (r.metadata.address) {
|
||||
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||
@@ -53,17 +46,14 @@ class DiscoveryReconciler {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||
|
||||
// Fallback matching by Slug or Name
|
||||
if (!existing && (res.slug || res.name)) {
|
||||
const inputName = normalizeHost(res.name || res.slug);
|
||||
existing = allRes.find(r => {
|
||||
if (res.slug && r.slug === res.slug) return true;
|
||||
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;
|
||||
});
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r =>
|
||||
(res.slug && r.slug === res.slug) ||
|
||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -29,8 +29,8 @@ describe('vault_broker admin policy', () => {
|
||||
return { status: 404, text: async () => '' };
|
||||
}
|
||||
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
|
||||
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }');
|
||||
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }');
|
||||
return { status: 204, ok: true };
|
||||
}
|
||||
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
|
||||
|
||||
@@ -44,6 +44,7 @@ module.exports = {
|
||||
{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: '/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>/*.
|
||||
{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']},
|
||||
|
||||
@@ -79,19 +79,17 @@ async function mintToken(policies) {
|
||||
return { token, ttl };
|
||||
}
|
||||
|
||||
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||
function userPolicyHcl(uid) {
|
||||
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/shared" { capabilities = ["read", "list"] }
|
||||
path "secret/data/shared/*" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared/" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`;
|
||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||
// into a policy path, so reject anything but a safe charset.
|
||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
||||
// directory itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
|
||||
@@ -109,13 +107,13 @@ async function getOrCreateUserToken(uid) {
|
||||
|
||||
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||
function adminPolicyHcl() {
|
||||
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
||||
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
|
||||
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
|
||||
// paths but NOT the root itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
async function getOrCreateAdminToken(uid) {
|
||||
@@ -130,16 +128,11 @@ async function getOrCreateAdminToken(uid) {
|
||||
|
||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||
function appPolicyHcl(name) {
|
||||
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/shared" { capabilities = ["read", "list"] }
|
||||
path "secret/data/shared/*" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared/" { capabilities = ["read", "list"] }
|
||||
path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`;
|
||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
||||
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||
@@ -163,12 +156,11 @@ async function mintAppToken(name) {
|
||||
// client's sso auth headers so OpenBao never sees them.
|
||||
|
||||
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';
|
||||
|
||||
async function isAdmin(user) {
|
||||
try {
|
||||
await permission.byGroup(user, ADMIN_GROUPS);
|
||||
await permission.byGroup(user, [ADMIN_GROUP]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -197,13 +189,17 @@ async function scopeGuard(req, res, next) {
|
||||
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||
}
|
||||
|
||||
// Defense-in-depth: confirm the requested path is within the subject's
|
||||
// namespace. Admins roam all of secret/; users are confined to
|
||||
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
||||
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
||||
// 403 instead of an opaque OpenBao denial.)
|
||||
const norm = normalizeVaultPath(req.path);
|
||||
if (norm === null) {
|
||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||
}
|
||||
const userBase = `/secret/users/${uid}`;
|
||||
const sharedBase = `/secret/shared`;
|
||||
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
||||
const base = `/secret/users/${uid}`;
|
||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||
}
|
||||
|
||||
+262
-292
@@ -1,16 +1,11 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||
|
||||
var messagingTypes = {};
|
||||
var messagingPlugins = [];
|
||||
|
||||
$(document).ready(function() {
|
||||
loadConf();
|
||||
loadProxyConf();
|
||||
loadTos();
|
||||
loadMessagingPlugins();
|
||||
});
|
||||
|
||||
async function loadConf() {
|
||||
@@ -49,7 +44,7 @@
|
||||
|
||||
async function saveConf() {
|
||||
const btn = $('#btn-save');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||
|
||||
const payload = {
|
||||
smtp: {
|
||||
@@ -77,11 +72,11 @@
|
||||
|
||||
try {
|
||||
await app.api.post('conf', payload);
|
||||
app.messages.toast('Configuration saved successfully!', 'success');
|
||||
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
||||
} catch (error) {
|
||||
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||
} finally {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +87,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-test-email');
|
||||
const $inputGroup = $('#test-email-to').closest('.input-group');
|
||||
const btn = $inputGroup.find('button');
|
||||
const originalHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
|
||||
|
||||
try {
|
||||
// First save the SMTP config, then send test email
|
||||
const payload = {
|
||||
smtp: {
|
||||
host: $('#smtp-host').val(),
|
||||
@@ -107,7 +104,11 @@
|
||||
secure: $('#smtp-secure').is(':checked')
|
||||
}
|
||||
};
|
||||
|
||||
// Save config first
|
||||
await app.api.post('conf', payload);
|
||||
|
||||
// Then send test email
|
||||
const result = await app.api.post('conf/test-email', { to });
|
||||
app.messages.toast(result.message || 'Test email sent!', 'success');
|
||||
$('#test-email-to').val('');
|
||||
@@ -125,11 +126,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-test-sms');
|
||||
const $inputGroup = $('#test-sms-to').closest('.input-group');
|
||||
const btn = $inputGroup.find('button');
|
||||
const originalHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
|
||||
|
||||
try {
|
||||
// First save the VoIP.ms config, then send test SMS
|
||||
const payload = {
|
||||
voipms: {
|
||||
username: $('#voipms-username').val(),
|
||||
@@ -137,7 +140,11 @@
|
||||
password: $('#voipms-password').val()
|
||||
}
|
||||
};
|
||||
|
||||
// Save config first
|
||||
await app.api.post('conf', payload);
|
||||
|
||||
// Then send test SMS
|
||||
const result = await app.api.post('conf/test-sms', { to });
|
||||
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
||||
$('#test-sms-to').val('');
|
||||
@@ -175,7 +182,7 @@
|
||||
|
||||
async function saveProxyConf() {
|
||||
const btn = $('#btn-save-proxy');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||
|
||||
const payload = {
|
||||
oidc: {
|
||||
@@ -194,18 +201,22 @@
|
||||
} catch (error) {
|
||||
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||
} finally {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></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() {
|
||||
try {
|
||||
const tos = await app.tos.get();
|
||||
if (tos && tos.content) {
|
||||
document.getElementById('tos-content').value = tos.content;
|
||||
document.getElementById('tos-meta').textContent =
|
||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||
}
|
||||
document.getElementById('tos-content').value = tos.content;
|
||||
document.getElementById('tos-meta').textContent =
|
||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||
} catch(e) {
|
||||
console.error('Failed to load ToS:', e);
|
||||
}
|
||||
@@ -237,287 +248,246 @@
|
||||
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-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></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>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
|
||||
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
||||
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button>
|
||||
</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">
|
||||
<!-- SMTP Tab -->
|
||||
<div class="tab-pane fade show active" id="smtp" 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-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" id="smtp-host">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Port</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>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
|
||||
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
|
||||
</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>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
|
||||
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
|
||||
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
|
||||
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</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">
|
||||
<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>
|
||||
</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>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-access">
|
||||
</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 class="card-body p-4">
|
||||
<div class="tab-content" id="confTabContent">
|
||||
|
||||
<!-- OAuth & JWT Tab -->
|
||||
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
|
||||
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<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>
|
||||
|
||||
<!-- SMTP Tab -->
|
||||
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
|
||||
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
|
||||
<!-- SMS & Messaging Tab -->
|
||||
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
|
||||
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
|
||||
<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">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">DID Sender Number</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">API Password</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-light rounded border mb-4">
|
||||
<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">
|
||||
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
||||
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
|
||||
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Proxy Secrets Tab -->
|
||||
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
|
||||
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
|
||||
|
||||
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="row">
|
||||
<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">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Client Secret</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Proxy Bind Password</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Terms of Service Tab -->
|
||||
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
|
||||
<span class="small text-muted" id="tos-meta"></span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
|
||||
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
|
||||
</div>
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
|
||||
</div>
|
||||
<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-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- SMS Tab -->
|
||||
<div class="tab-pane fade" id="sms" 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-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
||||
</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">
|
||||
<label class="form-label">API Username</label>
|
||||
<input type="text" class="form-control" id="voipms-username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">DID (sender number)</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Password</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Secrets Tab -->
|
||||
<div class="tab-pane fade" id="proxy" 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-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
|
||||
|
||||
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input type="text" class="form-control" id="proxy-client-id">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4 mb-2">LDAP Integration</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bind Password</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text">Password for the Proxy's LDAP service account.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ToS Tab -->
|
||||
<div class="tab-pane fade" id="tos" role="tabpanel">
|
||||
<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">
|
||||
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
|
||||
<small class="text-muted" id="tos-meta"></small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
||||
<textarea class="form-control" id="tos-content" rows="8"></textarea>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
|
||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+8
-164
@@ -13,12 +13,7 @@
|
||||
</li>
|
||||
<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">
|
||||
<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
|
||||
<i class="fa-solid fa-network-wired"></i> Discovery
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -192,23 +187,6 @@
|
||||
</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>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
|
||||
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discovery-plugins-list" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1210,7 +1188,6 @@
|
||||
allEdges.push(res.results);
|
||||
refreshEdgesUI(resourceId);
|
||||
$('#new-edge-target').val('');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||
@@ -1222,7 +1199,6 @@
|
||||
await app.api.delete('directory-admin/edges/' + id);
|
||||
allEdges = allEdges.filter(e => e.id !== id);
|
||||
refreshEdgesUI($('#res-id').val());
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||
@@ -1262,10 +1238,9 @@
|
||||
function renderDiscoveryTable() {
|
||||
const search = $('#discovery-search-filter').val().toLowerCase();
|
||||
const filtered = allDiscoveryResources.filter(r => {
|
||||
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
|
||||
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
|
||||
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
|
||||
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||
if(isManaged) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1518,7 +1493,7 @@
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
title: 'Install Theta Agent',
|
||||
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent',
|
||||
bodyHtml: bodyHtml,
|
||||
size: 'lg'
|
||||
});
|
||||
@@ -1526,143 +1501,12 @@
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
var discoveryPlugins = [];
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
var discoveryPluginTypes = [];
|
||||
|
||||
function openNewDiscoveryPluginModal() {
|
||||
app.api.get('plugins/types', function(err, res) {
|
||||
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
|
||||
if (discoveryPluginTypes.length === 0) {
|
||||
app.messages.toast('No discovery plugin types available', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
|
||||
const bodyHtml = `
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Plugin Type</label>
|
||||
<select id="new-plugin-type" class="form-select shadow-sm">${options}</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Instance Name</label>
|
||||
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Slug</label>
|
||||
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Cron Schedule</label>
|
||||
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
|
||||
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
|
||||
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
title: 'Configure New Discovery Plugin',
|
||||
bodyHtml: bodyHtml,
|
||||
size: 'md'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNewDiscoveryPlugin() {
|
||||
const type = $('#new-plugin-type').val();
|
||||
const name = $('#new-plugin-name').val().trim();
|
||||
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
|
||||
const enabled = $('#new-plugin-enabled').is(':checked');
|
||||
|
||||
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||
|
||||
try {
|
||||
await app.api.post('plugins', {
|
||||
pluginType: type,
|
||||
name,
|
||||
slug,
|
||||
cron,
|
||||
enabled,
|
||||
config: {}
|
||||
});
|
||||
app.messages.toast('Discovery plugin created successfully!', 'success');
|
||||
app.modal.close();
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
|
||||
// Scheduler tab here was its old home). Discovery inventory + the discovery
|
||||
// results table remain on this page.
|
||||
|
||||
$(document).ready(function(){
|
||||
loadDiscoveryResources();
|
||||
loadDiscoveryPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -134,12 +134,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
// key relative to the subject's namespace (so 'foo' for a user means
|
||||
// secret/data/users/<uid>/foo).
|
||||
function vpath(kind, key) {
|
||||
let cleanKey = key || '';
|
||||
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
|
||||
if (VAULT_BASE) {
|
||||
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
|
||||
}
|
||||
return `secret/${kind}/${cleanKey}`;
|
||||
return `secret/${kind}/${VAULT_BASE}${key}`;
|
||||
}
|
||||
|
||||
function apiCall(method, path, body = null) {
|
||||
@@ -161,8 +156,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
|
||||
async function loadSecrets() {
|
||||
try {
|
||||
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
|
||||
const res = await apiCall('GET', listPath);
|
||||
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
||||
const listEl = document.getElementById('secrets-list');
|
||||
listEl.innerHTML = '';
|
||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user