release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)
Plugin visibility control, UI/UX fixes, unified admin panel, database-backed settings, and security hardening. Highlights: - ENABLED_PLUGINS env var for plugin visibility (F.1) - Admin designation via ADMIN_EMAILS (F.4a) - Master key scope control (F.4b) - Panel unification + settings from UI (F.4c) - exec() removal, shell injection fix, bcrypt migration (F.8) - 5 new test suites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}">{{ pname }}</option>
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -82,11 +82,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
|
||||
const advFields = pluginFields[ptype].filter(f => f.advanced);
|
||||
|
||||
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||
pluginFields[ptype].forEach(field => {
|
||||
|
||||
function renderField(field) {
|
||||
const req = field.required ? 'required' : '';
|
||||
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
|
||||
html += `
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<input type="${field.type}" name="cred_${field.name}" ${req}
|
||||
@@ -94,10 +98,29 @@
|
||||
placeholder="${field.label}">
|
||||
${hintHtml}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
mainFields.forEach(field => { html += renderField(field); });
|
||||
|
||||
if (advFields.length > 0) {
|
||||
html += `
|
||||
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="px-4 pb-4 pt-2">`;
|
||||
advFields.forEach(field => { html += renderField(field); });
|
||||
html += `</div></details>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
|
||||
if (document.getElementById('plugin_type').value) {
|
||||
updateFields();
|
||||
}
|
||||
|
||||
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
|
||||
@@ -53,18 +53,48 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
{% if not field.get('advanced', False) %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %}
|
||||
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||
data-required="{{ field.required | lower }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||
{% if has_advanced %}
|
||||
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||
{% for field in fields %}
|
||||
{% if field.get('advanced', False) %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{{ t.leave_blank_to_clear }}"
|
||||
data-required="false"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
@@ -99,12 +129,20 @@
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
|
||||
// Collect credentials — only include non-empty values
|
||||
// Collect credentials — non-empty values always sent,
|
||||
// optional fields sent as empty string to allow clearing
|
||||
const creds = {};
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
if (!input) return;
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
creds[field.name] = val;
|
||||
} else if (input.dataset.required === 'false') {
|
||||
// Send empty string for optional fields so server can clear them
|
||||
creds[field.name] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,19 +52,26 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-6 py-4" id="status-cell-{{ site.id }}">
|
||||
{% if site.status == 'active' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||
{% elif site.status == 'error' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||
{% elif site.status == 'pending' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||
{% endif %}
|
||||
{% if site.status_msg %}
|
||||
<p class="text-xs text-gray-500 mt-1">{{ site.status_msg[:60] }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1 status-msg">{{ site.status_msg[:60] }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
|
||||
{% if site.last_tested_at %}
|
||||
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
|
||||
{% else %}
|
||||
{{ t.never_tested }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -107,29 +114,68 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function updateStatusBadge(siteId, status) {
|
||||
const cell = document.getElementById('status-cell-' + siteId);
|
||||
if (!cell) return;
|
||||
const badge = cell.querySelector('.status-badge');
|
||||
if (badge) {
|
||||
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
if (status === 'active') {
|
||||
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
|
||||
badge.textContent = '{{ t.active }}';
|
||||
} else {
|
||||
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
|
||||
badge.textContent = '{{ t.error }}';
|
||||
}
|
||||
}
|
||||
const msgEl = cell.querySelector('.status-msg');
|
||||
if (msgEl) msgEl.remove();
|
||||
const timeEl = cell.querySelector('.last-tested-time');
|
||||
if (timeEl) {
|
||||
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
|
||||
}
|
||||
}
|
||||
|
||||
async function testSite(siteId) {
|
||||
const btn = document.getElementById('test-btn-' + siteId);
|
||||
btn.textContent = '{{ t.testing }}';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
return;
|
||||
}
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = '{{ t.connection_ok }}';
|
||||
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
updateStatusBadge(siteId, data.status || 'active');
|
||||
} else {
|
||||
btn.textContent = data.message || '{{ t.connection_failed }}';
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, data.status || 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = '{{ t.error }}';
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
btn.textContent = '{{ t.test_connection }}';
|
||||
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = '{{ t.test_connection }}';
|
||||
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function deleteSite(siteId) {
|
||||
|
||||
Reference in New Issue
Block a user