fix: SUG-6 API key modal, SUG-4 project activity filter, WooCommerce health auth

- SUG-6: Replace auto-closing inline banner with persistent modal on Connect
  page — key stays visible until user clicks "Done"
- SUG-4: Filter health_metric_recorded/health_check from project detail activity
- WooCommerce: Accept consumer_key/consumer_secret credentials (preferred) with
  username/app_password fallback; fix health check to use WC endpoint and creds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-24 07:16:05 +03:30
parent 983d93c2a7
commit 1736779d69
5 changed files with 86 additions and 28 deletions

View File

@@ -918,12 +918,15 @@ async def get_project_detail(project_id: str) -> dict | None:
# Get recent activity for this project # Get recent activity for this project
audit_logger = get_audit_logger() audit_logger = get_audit_logger()
recent_entries = audit_logger.get_recent_entries(limit=20) recent_entries = audit_logger.get_recent_entries(limit=60)
project_activity = [ project_activity = [
e e
for e in recent_entries for e in recent_entries
if e.get("metadata", {}).get("project_id") == project_id if (
e.get("metadata", {}).get("project_id") == project_id
or e.get("metadata", {}).get("site") == site_id or e.get("metadata", {}).get("site") == site_id
)
and e.get("event_type", "") not in ("health_metric_recorded", "health_check")
][:5] ][:5]
# Get cached health status # Get cached health status

View File

@@ -563,13 +563,26 @@ class HealthMonitor:
try: try:
import aiohttp import aiohttp
if plugin_type == "woocommerce":
# WooCommerce uses consumer_key/consumer_secret
ck = getattr(config, "consumer_key", None) or ""
cs = getattr(config, "consumer_secret", None) or ""
# consumer_key/consumer_secret may be in model_extra
if not ck and hasattr(config, "model_extra"):
ck = (config.model_extra or {}).get("consumer_key", "")
cs = (config.model_extra or {}).get("consumer_secret", "")
auth = aiohttp.BasicAuth(ck, cs)
auth_check_url = f"{config.url}/wp-json/wc/v3/system_status"
else:
auth = aiohttp.BasicAuth( auth = aiohttp.BasicAuth(
config.username or "", config.username or "",
config.app_password or "", config.app_password or "",
) )
auth_check_url = f"{config.url}/wp-json/wp/v2/users/me"
async with aiohttp.ClientSession(auth=auth) as session: async with aiohttp.ClientSession(auth=auth) as session:
async with session.get( async with session.get(
f"{config.url}/wp-json/wp/v2/users/me", auth_check_url,
timeout=aiohttp.ClientTimeout(total=10), timeout=aiohttp.ClientTimeout(total=10),
ssl=False, ssl=False,
) as resp: ) as resp:

View File

@@ -28,14 +28,35 @@
</div> </div>
{% endif %} {% endif %}
<!-- New key display (JS-created) --> <!-- New Key Modal (stays open until user clicks Done) -->
<div id="new-key-display" class="hidden bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4"> <div id="new-key-display" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<p class="font-medium mb-1">{{ t.your_api_key }}</p> <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="flex items-center gap-2"> <div class="p-6 border-b border-gray-200 dark:border-gray-700">
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code> <h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button> <svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ t.your_api_key }}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ t.key_shown_once }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">API Key</label>
<div class="flex items-center gap-2">
<code id="new-key-value" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
<button onclick="copyNewKey()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">{{ t['copy'] }}</button>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}تأیید{% else %}Done{% endif %}
</button>
</div>
</div> </div>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
</div> </div>
{% if api_keys %} {% if api_keys %}
@@ -132,8 +153,8 @@ async function createKey() {
if (resp.ok && data.key) { if (resp.ok && data.key) {
document.getElementById('new-key-value').textContent = data.key.key; document.getElementById('new-key-value').textContent = data.key.key;
document.getElementById('new-key-display').classList.remove('hidden'); document.getElementById('new-key-display').classList.remove('hidden');
// Reload to show in table document.getElementById('new-key-display').classList.add('flex');
setTimeout(() => location.reload(), 500); // Modal stays open — user clicks "Done" to dismiss
} else { } else {
alert(data.error || 'Failed to create key'); alert(data.error || 'Failed to create key');
} }
@@ -180,6 +201,12 @@ function copyNewKey() {
navigator.clipboard.writeText(key); navigator.clipboard.writeText(key);
} }
function closeNewKeyModal() {
document.getElementById('new-key-display').classList.add('hidden');
document.getElementById('new-key-display').classList.remove('flex');
location.reload();
}
function copyConfig() { function copyConfig() {
const text = document.getElementById('config-output').textContent; const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);

View File

@@ -47,7 +47,7 @@ class WooCommercePlugin(BasePlugin):
@staticmethod @staticmethod
def get_required_config_keys() -> list[str]: def get_required_config_keys() -> list[str]:
"""Return required configuration keys""" """Return required configuration keys"""
return ["url", "username", "app_password"] return ["url"]
def __init__(self, config: dict[str, Any], project_id: str | None = None): def __init__(self, config: dict[str, Any], project_id: str | None = None):
""" """
@@ -55,16 +55,30 @@ class WooCommercePlugin(BasePlugin):
Args: Args:
config: Configuration dictionary containing: config: Configuration dictionary containing:
- url: WordPress site URL - url: WordPress/WooCommerce site URL
- username: WordPress username - consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
- app_password: WordPress application password - username/app_password: WordPress application password (fallback)
project_id: Optional project ID (auto-generated if not provided) project_id: Optional project ID (auto-generated if not provided)
""" """
super().__init__(config, project_id=project_id) super().__init__(config, project_id=project_id)
# Create WordPress API client (WooCommerce uses WordPress REST API) # WooCommerce supports two credential formats:
# 1. consumer_key/consumer_secret (WooCommerce REST API keys — preferred)
# 2. username/app_password (WordPress Application Passwords — legacy fallback)
username = config.get("consumer_key") or config.get("username")
password = config.get("consumer_secret") or config.get("app_password")
if not username or not password:
from plugins.wordpress.client import ConfigurationError
raise ConfigurationError(
"WooCommerce credentials not configured. "
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
)
# Create WordPress API client
self.client = WordPressClient( self.client = WordPressClient(
site_url=config["url"], username=config["username"], app_password=config["app_password"] site_url=config["url"], username=username, app_password=password
) )
# Initialize WooCommerce handlers # Initialize WooCommerce handlers

View File

@@ -44,11 +44,10 @@ class TestWooCommercePluginInit:
assert WooCommercePlugin.get_plugin_name() == "woocommerce" assert WooCommercePlugin.get_plugin_name() == "woocommerce"
def test_required_config_keys(self): def test_required_config_keys(self):
"""Should require url, username, app_password.""" """Should require url only (credentials checked at init)."""
keys = WooCommercePlugin.get_required_config_keys() keys = WooCommercePlugin.get_required_config_keys()
assert "url" in keys assert "url" in keys
assert "username" in keys assert len(keys) == 1
assert "app_password" in keys
def test_missing_url_raises(self): def test_missing_url_raises(self):
"""Should raise ValueError for missing URL.""" """Should raise ValueError for missing URL."""
@@ -57,9 +56,11 @@ class TestWooCommercePluginInit:
WooCommercePlugin(config) WooCommercePlugin(config)
def test_missing_credentials_raises(self): def test_missing_credentials_raises(self):
"""Should raise ValueError for missing credentials.""" """Should raise ConfigurationError for missing credentials."""
from plugins.wordpress.client import ConfigurationError
config = {"url": "https://shop.example.com"} config = {"url": "https://shop.example.com"}
with pytest.raises(ValueError, match="Missing required configuration"): with pytest.raises(ConfigurationError, match="credentials not configured"):
WooCommercePlugin(config) WooCommercePlugin(config)
def test_custom_project_id(self): def test_custom_project_id(self):