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>
This commit is contained in:
+63
-13
@@ -128,12 +128,17 @@
|
||||
// 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 (val === undefined || val === null) val = '';
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
@@ -149,6 +154,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 +231,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 +248,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 +273,10 @@
|
||||
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></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 +288,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 {
|
||||
|
||||
Reference in New Issue
Block a user