Compare commits

...

4 Commits

Author SHA1 Message Date
wmantly 3ca221d64d chore: release v1.18.0 2026-08-02 00:16:18 -04:00
wmantly 75b133f610 feat: Add messaging plugins, Docker discovery, fix reconciliation 2026-08-02 00:16:17 -04:00
wmantly f1d52601de Merge pull request #140 from theta42/feature/v1.17.2-fixes
v1.17.2: post-deploy fixes + SMS/TOS on /conf
2026-08-01 22:51:55 -04:00
wmantly ecd21c4984 feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf
- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:47:37 -04:00
20 changed files with 612 additions and 124 deletions
+69
View File
@@ -1,9 +1,78 @@
# v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
### Fixed
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
`views/plugins.ejs`)
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
(nested paths), never on the directory path itself — so listing a directory's
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
already-created `user-<uid>` policies pick up the new grant on the next
vault-page visit. The matching `sso-admin` mount-root grant ships in
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
always-write so re-running `./setup.sh` applies policy edits.
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
buttons. The header/label are now populated by JS (the `Members` label
already had a setter pointing at a missing id); the Admin Actions block is
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but `PUT /:id` only saves
non-secret config — so an edited secret was silently dropped. The Edit modal
now shows **non-secret fields only** (secrets have their own Edit-Secrets
modal), removing the confusion. (`views/plugins.ejs`)
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
binary was not installed in the app image. `Dockerfile.openldap` now `apk
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
the opaque node-nmap spawn-missing error into an actionable `lastError`.
### Added
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
config files. It now has an SMS card on `/conf` (API username, DID, API
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
the API password masked (`********`) and leave-blank-to-keep — mirroring the
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
saved change takes effect live without a restart. (`routes/api_conf.js`,
`views/conf.ejs`)
- **Terms of Service editor moved to `/conf`** from the admin Overview
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
### Notes
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
policies self-heal on the next vault-page visit.
## [1.17.1] - 2026-08-01
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
+1
View File
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
dumb-init \
bash \
redis \
nmap \
&& rm -rf /var/cache/apk/*
COPY --from=ldapbuild /opt/openldap /opt/openldap
Binary file not shown.
+15
View File
@@ -10,6 +10,21 @@ function toE164Digits(number) {
}
async function send(to, message) {
const { PluginInstance } = require('./plugin_instance');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
if (manifest && manifest.sendMessage) {
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
const config = { ...inst.config, ...secrets };
return manifest.sendMessage(config, { to, message });
}
}
const params = new URLSearchParams({
api_username: conf.username,
api_password: conf.password,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.18.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.17.1",
"version": "1.18.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+81
View File
@@ -0,0 +1,81 @@
const http = require('http');
module.exports = {
type: 'docker',
category: 'discovery',
name: 'Docker Daemon',
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
],
validate: async (config) => {
if (!config.socketPath && !config.tcpHost) {
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
}
return { ok: true };
},
discover: async (config) => {
const isTcp = !!config.tcpHost;
const requestOptions = {
path: '/containers/json',
method: 'GET'
};
if (isTcp) {
const url = new URL(config.tcpHost);
requestOptions.host = url.hostname;
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
requestOptions.protocol = url.protocol;
} else {
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
}
return new Promise((resolve, reject) => {
const req = http.request(requestOptions, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
}
try {
const containers = JSON.parse(body);
const resources = [];
const edges = [];
for (const c of containers) {
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
resources.push({
kind: 'container',
name: name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
}
});
}
resolve({ resources, edges });
} catch (e) {
reject(new Error(`Failed to parse Docker response: ${e.message}`));
}
});
});
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
req.end();
});
}
};
+12 -1
View File
@@ -32,6 +32,7 @@ module.exports = {
return new Promise((resolve, reject) => {
const scan = new nmap.OsAndPortScan(targetRange);
scan.command.push('-Pn');
scan.on('complete', function(data) {
const resources = [];
const edges = [];
@@ -66,7 +67,17 @@ module.exports = {
});
scan.on('error', function(error) {
reject(error);
// node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
}
});
scan.startScan();
+4 -1
View File
@@ -49,7 +49,10 @@ module.exports = {
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
if(!resNodes.ok) {
const errText = await resNodes.text();
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
}
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
+61
View File
@@ -0,0 +1,61 @@
const https = require('https');
module.exports = {
type: 'twilio',
category: 'messaging',
name: 'Twilio SMS',
description: 'Send SMS messages (like 2FA codes) via Twilio.',
configSchema: [
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
],
validate: async (config) => {
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
const data = new URLSearchParams();
data.append('To', to);
data.append('From', config.fromNumber);
data.append('Body', message);
const postData = data.toString();
const options = {
hostname: 'api.twilio.com',
port: 443,
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
};
+79
View File
@@ -0,0 +1,79 @@
const https = require('https');
const http = require('http');
module.exports = {
type: 'webhook',
category: 'messaging',
name: 'Universal REST Webhook',
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
configSchema: [
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
],
validate: async (config) => {
if (!config.url) return { ok: false, error: 'URL is required' };
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
try {
if (config.headers) JSON.parse(config.headers);
} catch (e) {
return { ok: false, error: 'Headers must be valid JSON' };
}
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
let payloadStr = config.payloadTemplate || '{}';
// Replace template variables safely
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
// If there is an API secret, replace {{secret}} in the headers or url
let headersObj = {};
if (config.headers) {
try {
const parsed = JSON.parse(config.headers);
for (const [k, v] of Object.entries(parsed)) {
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
}
} catch(e) {}
}
if (!headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
const urlObj = new URL(config.url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: config.method || 'POST',
headers: headersObj
};
const client = urlObj.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body });
} else {
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(payloadStr);
req.end();
});
}
};
+3 -1
View File
@@ -21,6 +21,7 @@ const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
@@ -35,7 +36,8 @@ router.get('/', async (req, res) => {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {}
oauth: conf.oauth || {},
voipms: conf.voipms || {}
});
res.json(editable);
});
+33 -2
View File
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => {
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' });
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
// Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret +
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
pluginType,
category: manifest.category,
name,
slug,
slug: finalSlug,
enabled,
cron: cron || '0 * * * *',
config,
+14 -5
View File
@@ -12,14 +12,14 @@ class DiscoveryReconciler {
let existing = null;
// Attempt matching by MAC if available
// 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).filter(m => !!m);
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.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac))
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
);
}
}
@@ -65,7 +65,10 @@ class DiscoveryReconciler {
const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip
for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip));
const idx = existingIntfs.findIndex(ei =>
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
(ni.ip && ei.ip && ei.ip === ni.ip)
);
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni);
}
@@ -79,8 +82,14 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
}
await existing.update({
name: res.name || existing.name,
name: bestName,
description: res.description || existing.description,
metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000)
+14 -3
View File
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
return res;
}
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
// a list grant on a directory path) propagate on the next vault-page visit
// without an operator re-running setup.sh. Skipping on an existing policy
// would strand the old, narrower HCL forever.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) return;
if (existing.status !== 404) {
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
}
@@ -80,7 +83,12 @@ async function mintToken(policies) {
function userPolicyHcl(uid) {
// 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"] }`;
}
@@ -109,7 +117,10 @@ async function getOrCreateAdminToken(uid) {
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
// 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"] }`;
}
+133 -9
View File
@@ -4,6 +4,7 @@
$(document).ready(function() {
loadConf();
loadTos();
});
async function loadConf() {
@@ -28,6 +29,13 @@
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
}
}
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
}
@@ -53,6 +61,11 @@
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
@@ -74,6 +87,50 @@
el.type = 'password';
}
}
// ── 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();
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);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
</script>
<div class="container py-4">
@@ -82,10 +139,10 @@
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings and OAuth parameters.
These are stored securely in OpenBao and take effect immediately. Secret fields
(the SMTP password and OAuth JWT secret) are masked — leave them unchanged to
keep the stored value.
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>
@@ -95,9 +152,25 @@
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<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>
</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>
@@ -134,8 +207,9 @@
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<!-- 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>
@@ -163,6 +237,56 @@
</div>
</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>
</div>
</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>
</div>
</div>
+3
View File
@@ -136,6 +136,9 @@
const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false;
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
if(!isAuto) return false;
return true;
});
-64
View File
@@ -162,50 +162,10 @@
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
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);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
loadMetrics();
});
</script>
@@ -385,30 +345,6 @@
</div>
</div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<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 shadow-sm" id="tos-content" rows="12"></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 shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
+64 -13
View File
@@ -128,13 +128,19 @@
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
function configFormHtml(type, prefix, values) {
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {};
var html = '';
schema.forEach(function(f) {
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
@@ -149,6 +155,53 @@
return html;
}
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
@@ -179,10 +232,9 @@
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Slug <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('np-', '0 * * * *') +
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
});
@@ -197,13 +249,11 @@
var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim();
var slug = document.getElementById('np-slug').value.trim();
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
var cron = cronFromForm('np-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-');
try {
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config });
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins();
@@ -224,9 +274,10 @@
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</div>',
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
@@ -238,7 +289,7 @@
var p = pluginsById[id];
if (!p) return;
var name = document.getElementById('ed-name').value.trim();
var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *';
var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-');
try {
+23 -22
View File
@@ -9,6 +9,7 @@
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid);
$('#profile-uid-header').text(user.uid);
$.scope.user.update(user);
};
@@ -241,7 +242,7 @@
<div class="card-header shadow d-flex justify-content-between align-items-center">
<div>
<i class="fa-regular fa-id-card"></i>
Profile: <strong>{{user.uid}}</strong>
Profile: <strong id="profile-uid-header"></strong>
</div>
<div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
@@ -278,7 +279,7 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
</button>
</li>
</ul>
@@ -329,27 +330,27 @@
<p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p>
</div>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
</div>
</div>
</div>
</div>