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:
2026-03-31 09:55:49 +02:00
parent f6dbbeaab0
commit 9b70b259bb
47 changed files with 2122 additions and 366 deletions

View File

@@ -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');

View File

@@ -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] = '';
}
});
}

View File

@@ -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) {