sync: v3.2.0 — Supabase fixes, site edit, WP plugin rename, black formatting
- Supabase: fix filters, count_rows, upsert, anon_key optional, meta_url field - Dashboard: site edit page + PATCH update route - WP Plugin: rename SEO API Bridge → Airano MCP SEO Bridge - supabase/client.py: black formatting fix - .gitignore: exclude BUILD_REPORT.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "No sites added yet",
|
||||
"add_first_site": "Add your first site to get started",
|
||||
"site_added": "Site added successfully",
|
||||
"site_updated": "Site updated successfully",
|
||||
"site_deleted": "Site deleted",
|
||||
"edit_site": "Edit Site",
|
||||
"updating_site": "Updating site...",
|
||||
"keep_existing": "Leave blank to keep current value",
|
||||
"connection_ok": "Connection OK",
|
||||
"connection_failed": "Connection failed",
|
||||
"credentials": "Credentials",
|
||||
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "هنوز سایتی اضافه نشده",
|
||||
"add_first_site": "اولین سایت خود را اضافه کنید",
|
||||
"site_added": "سایت با موفقیت اضافه شد",
|
||||
"site_updated": "سایت با موفقیت بروزرسانی شد",
|
||||
"site_deleted": "سایت حذف شد",
|
||||
"edit_site": "ویرایش سایت",
|
||||
"updating_site": "در حال بروزرسانی...",
|
||||
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
|
||||
"connection_ok": "اتصال برقرار",
|
||||
"connection_failed": "اتصال ناموفق",
|
||||
"credentials": "مشخصات دسترسی",
|
||||
@@ -2811,6 +2819,82 @@ async def api_test_site(request: Request) -> Response:
|
||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||
|
||||
|
||||
async def dashboard_sites_edit(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
import json
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/sites/edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"plugin_names": plugin_names,
|
||||
"current_page": "my_sites",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def api_update_site(request: Request) -> Response:
|
||||
"""PATCH /api/sites/{id} — Update site URL and credentials."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
from core.site_api import update_user_site
|
||||
|
||||
try:
|
||||
site = await update_user_site(
|
||||
site_id=site_id,
|
||||
user_id=user_session["user_id"],
|
||||
url=body.get("url", "").strip().rstrip("/"),
|
||||
credentials=body.get("credentials", {}),
|
||||
)
|
||||
return JSONResponse({"site": site, "message": "Site updated"})
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
except RuntimeError as e:
|
||||
logger.error("Site update failed (runtime): %s", e)
|
||||
return JSONResponse(
|
||||
{"error": "Site storage is not configured. Contact the administrator."},
|
||||
status_code=503,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update site: %s", e, exc_info=True)
|
||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||
|
||||
|
||||
async def api_create_key(request: Request) -> Response:
|
||||
"""POST /api/keys — Create a new user API key."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
@@ -3090,6 +3174,7 @@ def register_dashboard_routes(mcp):
|
||||
# Site Management routes (E.3)
|
||||
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
||||
|
||||
# Site Management API (E.3)
|
||||
@@ -3097,6 +3182,7 @@ def register_dashboard_routes(mcp):
|
||||
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)
|
||||
mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site)
|
||||
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
|
||||
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
|
||||
|
||||
# User API Key routes (E.3)
|
||||
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
||||
|
||||
@@ -530,6 +530,30 @@ class Database:
|
||||
(status, status_msg, site_id),
|
||||
)
|
||||
|
||||
async def update_site_credentials(
|
||||
self,
|
||||
site_id: str,
|
||||
user_id: str,
|
||||
url: str,
|
||||
credentials: bytes,
|
||||
) -> bool:
|
||||
"""Update URL and credentials for an existing site.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID (for tenant isolation).
|
||||
url: New base URL for the site.
|
||||
credentials: New AES-256-GCM encrypted credentials blob.
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if site not found or not owned by user.
|
||||
"""
|
||||
cursor = await self.execute(
|
||||
"UPDATE sites SET url = ?, credentials = ?, status = 'pending' WHERE id = ? AND user_id = ?",
|
||||
(url, credentials, site_id, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||
"""Get a site by user ID and alias.
|
||||
|
||||
|
||||
121
core/site_api.py
121
core/site_api.py
@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||
"label": "Service Role Key",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → service_role key",
|
||||
"hint": (
|
||||
"Supabase Dashboard → Settings → API → service_role key. "
|
||||
"Note: On supabase.com cloud, postgres-meta tools "
|
||||
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
|
||||
"they only work on self-hosted Supabase."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "anon_key",
|
||||
"label": "Anon Key",
|
||||
"label": "Anon Key (Optional)",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → anon key",
|
||||
"required": False,
|
||||
"hint": (
|
||||
"Supabase Dashboard → Settings → API → anon key. "
|
||||
"Optional — if omitted, service_role_key is used for all calls."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "meta_url",
|
||||
"label": "postgres-meta URL (Optional)",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"hint": (
|
||||
"Only needed when /pg/ is not exposed through Kong (common on Coolify). "
|
||||
"Enter the direct postgres-meta container URL, e.g.: "
|
||||
"http://supabase-meta-<id>:8080 — "
|
||||
"Leave blank to use the default Kong /pg/ route."
|
||||
),
|
||||
},
|
||||
],
|
||||
"openpanel": [
|
||||
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||
return deleted
|
||||
|
||||
|
||||
async def update_user_site(
|
||||
site_id: str,
|
||||
user_id: str,
|
||||
url: str,
|
||||
credentials: dict[str, str],
|
||||
skip_validation: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Update URL and credentials for an existing site.
|
||||
|
||||
Password fields left blank are preserved from the existing encrypted credentials.
|
||||
Re-validates the connection after update.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID.
|
||||
url: New base URL (required).
|
||||
credentials: Credential dict — blank password fields keep their current value.
|
||||
skip_validation: If True, skip connection test (for testing).
|
||||
|
||||
Returns:
|
||||
The updated site dict (without decrypted credentials).
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found, validation fails, or connection test fails.
|
||||
"""
|
||||
from core.database import get_database
|
||||
from core.encryption import get_credential_encryption
|
||||
|
||||
db = get_database()
|
||||
|
||||
# Verify site ownership
|
||||
existing_site = await db.get_site(site_id, user_id)
|
||||
if existing_site is None:
|
||||
raise ValueError("Site not found")
|
||||
|
||||
plugin_type = existing_site["plugin_type"]
|
||||
|
||||
# Validate URL
|
||||
url = url.strip().rstrip("/")
|
||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
|
||||
# Merge new credentials with existing ones — blank fields keep existing values
|
||||
encryptor = get_credential_encryption()
|
||||
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
|
||||
|
||||
merged: dict[str, str] = dict(existing_credentials)
|
||||
for key, value in credentials.items():
|
||||
# Only override if the new value is non-empty
|
||||
if value and value.strip():
|
||||
merged[key] = value.strip()
|
||||
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
|
||||
else:
|
||||
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
|
||||
if key in field_defs and not field_defs[key].get("required", True):
|
||||
merged[key] = ""
|
||||
|
||||
# Strip empty optional values before storing (keep storage clean)
|
||||
merged = {k: v for k, v in merged.items() if v}
|
||||
|
||||
# Validate required fields are still present
|
||||
valid, errors = validate_credentials(plugin_type, merged)
|
||||
if not valid:
|
||||
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
|
||||
|
||||
# Test connection
|
||||
if not skip_validation:
|
||||
ok, msg = await validate_site_connection(plugin_type, url, merged)
|
||||
if not ok:
|
||||
raise ValueError(f"Connection test failed: {msg}")
|
||||
|
||||
# Encrypt merged credentials
|
||||
encrypted = encryptor.encrypt_credentials(merged, site_id)
|
||||
|
||||
# Persist
|
||||
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
|
||||
if not updated:
|
||||
raise RuntimeError(f"Failed to update site {site_id}")
|
||||
|
||||
# Mark active after successful connection test
|
||||
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
|
||||
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
|
||||
|
||||
result = await db.get_site(site_id, user_id)
|
||||
if result is None:
|
||||
raise RuntimeError(f"Failed to read back updated site {site_id}")
|
||||
|
||||
site_dict = dict(result)
|
||||
site_dict.pop("credentials", None)
|
||||
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
|
||||
return site_dict
|
||||
|
||||
|
||||
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||
"""Test connectivity to an existing site.
|
||||
|
||||
|
||||
134
core/templates/dashboard/sites/edit.html
Normal file
134
core/templates/dashboard/sites/edit.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.edit_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.edit_site }}: {{ site.alias }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Info notice about password fields -->
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||
{{ t.keep_existing }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="edit-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
|
||||
<!-- Plugin Type (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required value="{{ site.url }}"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<!-- Alias (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{{ site.alias }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<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 %}
|
||||
<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 %}
|
||||
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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.save }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const siteId = "{{ site.id }}";
|
||||
const pluginType = "{{ site.plugin_type }}";
|
||||
const pluginFields = {{ plugin_fields_json | safe }};
|
||||
|
||||
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errorEl = document.getElementById('error-msg');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
btn.textContent = '{{ t.updating_site }}';
|
||||
btn.disabled = true;
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
|
||||
// Collect credentials — only include non-empty values
|
||||
const creds = {};
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url, credentials: creds }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to update site';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Network error: ' + err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -73,6 +73,10 @@
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</button>
|
||||
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
{{ t.edit }}
|
||||
</a>
|
||||
<button onclick="deleteSite('{{ site.id }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{{ t.delete }}
|
||||
|
||||
Reference in New Issue
Block a user