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:
@@ -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 (
|
||||||
or e.get("metadata", {}).get("site") == site_id
|
e.get("metadata", {}).get("project_id") == project_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
|
||||||
|
|||||||
@@ -563,13 +563,26 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
auth = aiohttp.BasicAuth(
|
if plugin_type == "woocommerce":
|
||||||
config.username or "",
|
# WooCommerce uses consumer_key/consumer_secret
|
||||||
config.app_password or "",
|
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(
|
||||||
|
config.username 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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user