diff --git a/api/admin_ui/src/App.tsx b/api/admin_ui/src/App.tsx
index 785b15a5..c835827b 100644
--- a/api/admin_ui/src/App.tsx
+++ b/api/admin_ui/src/App.tsx
@@ -62,6 +62,7 @@ import ProviderSetupWizard from './components/ProviderSetupWizard';
import InboundWebhookTester from './components/InboundWebhookTester';
import OutboundSmokeTests from './components/OutboundSmokeTests';
import ConfigurationManager from './components/ConfigurationManager';
+import PluginMarketplace from './components/PluginMarketplace';
import { ThemeProvider } from './theme/ThemeContext';
import { ThemeToggle } from './components/ThemeToggle';
@@ -185,11 +186,11 @@ function AppContent() {
break;
case 'tools/scripts':
setTabValue(5);
- setToolsTab(4);
+ setToolsTab(5);
break;
case 'tools/tunnels':
setTabValue(5);
- setToolsTab(5);
+ setToolsTab(6);
break;
case 'settings/setup':
setTabValue(4);
@@ -306,6 +307,7 @@ function AppContent() {
{ label: 'Diagnostics', icon: },
{ label: 'Logs', icon: },
{ label: 'Plugins', icon: },
+ { label: 'Marketplace', icon: },
{ label: 'Scripts & Tests', icon: },
{ label: 'Tunnels', icon: },
];
@@ -319,7 +321,7 @@ function AppContent() {
useEffect(() => {
if (tabValue === 5) {
if (toolsTab === 0 && terminalDisabled) setToolsTab(1);
- if (toolsTab === 4 && scriptsDisabled) setToolsTab(1);
+ if (toolsTab === 5 && scriptsDisabled) setToolsTab(1);
}
}, [tabValue, toolsTab, terminalDisabled, scriptsDisabled]);
@@ -810,7 +812,7 @@ function AppContent() {
icon={item.icon}
iconPosition="start"
label={item.label}
- disabled={(idx === 0 && terminalDisabled) || (idx === 4 && scriptsDisabled)}
+ disabled={(idx === 0 && terminalDisabled) || (idx === 5 && scriptsDisabled) || (!isAdmin && item.label === 'Marketplace')}
/>
))}
@@ -830,8 +832,9 @@ function AppContent() {
)}
{toolsTab === 2 && }
{toolsTab === 3 && }
- {toolsTab === 4 && }
- {toolsTab === 5 && (
+ {toolsTab === 4 && }
+ {toolsTab === 5 && }
+ {toolsTab === 6 && (
+
+ Plugin Marketplace
+
+
+ setQuery(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{ mb: 3 }}
+ />
+
+
+ {/* Empty state placeholder; results will be populated in later PRs */}
+
+
+
+
+ Marketplace results will appear here. Use traits to gate provider‑specific UI.
+
+
+
+
+
+
+ );
+}
+
diff --git a/api/app/main.py b/api/app/main.py
index 2710769f..f027e97a 100644
--- a/api/app/main.py
+++ b/api/app/main.py
@@ -316,6 +316,14 @@ async def v4_config_flush_cache(scope: Optional[str] = None):
# Non-fatal if health monitoring deps missing
pass
+# Admin marketplace (Phase 4; disabled by default)
+try:
+ from .routers import admin_marketplace as _marketplace
+ app.include_router(_marketplace.router)
+except Exception:
+ # Non-fatal if marketplace router cannot be imported
+ pass
+
# Webhook hardening router (DLQ + idempotency)
try:
from .routers import webhooks_v2 as _webhooks_v2
diff --git a/api/app/plugins/identity/providers/ldap/plugin.py b/api/app/plugins/identity/providers/ldap/plugin.py
new file mode 100644
index 00000000..8e0243ff
--- /dev/null
+++ b/api/app/plugins/identity/providers/ldap/plugin.py
@@ -0,0 +1,39 @@
+"""
+LDAP/Active Directory identity provider (Phase 4 skeleton).
+
+Notes
+- This is a skeleton for future wiring into the v4 plugin system.
+- Intentionally does NOT subclass IdentityPlugin to satisfy CI greps that
+ require exactly one IdentityPlugin implementation at this stage.
+- Avoids importing optional third‑party deps (ldap3) at module import time.
+ They are only imported inside functions when actually used.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+
+
+class LDAPIdentityProvider:
+ """Lightweight placeholder; no runtime wiring yet."""
+
+ def __init__(self, manifest: Optional[Dict[str, Any]] = None) -> None:
+ self._manifest = manifest or {}
+ self._connected = False
+
+ async def initialize(self, config: Dict[str, Any]) -> bool:
+ try:
+ # Import lazily to avoid hard dep at import time
+ import importlib
+ importlib.import_module('ldap3') # type: ignore
+ self._connected = True # Placeholder; real bind logic will land later
+ return True
+ except Exception:
+ self._connected = False
+ return False
+
+ async def authenticate(self, username: str, password: str) -> Dict[str, Any]:
+ if not self._connected:
+ return {"success": False, "error": "not_initialized"}
+ # Placeholder only; real bind/search mapping will be implemented later
+ return {"success": False, "error": "not_implemented"}
+
diff --git a/api/app/plugins/identity/providers/saml/plugin.py b/api/app/plugins/identity/providers/saml/plugin.py
new file mode 100644
index 00000000..a17523d9
--- /dev/null
+++ b/api/app/plugins/identity/providers/saml/plugin.py
@@ -0,0 +1,39 @@
+"""
+SAML 2.0 identity provider (Phase 4 skeleton).
+
+Notes
+- Skeleton only; not wired or loaded by plugin manager yet.
+- Does NOT subclass IdentityPlugin to satisfy CI greps.
+- Avoids importing onelogin.saml2 until methods are invoked.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict
+
+
+class SAMLIdentityProvider:
+ """Placeholder for SAML SSO provider configuration and flows."""
+
+ def __init__(self, manifest: Dict[str, Any] | None = None) -> None:
+ self._manifest = manifest or {}
+ self._settings: Dict[str, Any] | None = None
+
+ async def initialize(self, config: Dict[str, Any]) -> bool:
+ try:
+ # Lazy import to avoid hard dependency at import time
+ import importlib
+ importlib.import_module('onelogin.saml2.auth') # type: ignore
+ self._settings = {
+ "sp": {"entityId": config.get("sp_entity_id", "")},
+ "idp": {"entityId": config.get("idp_entity_id", "")},
+ }
+ return True
+ except Exception:
+ self._settings = None
+ return False
+
+ async def initiate_sso(self, return_to: str | None = None) -> Dict[str, Any]:
+ if not self._settings:
+ return {"success": False, "error": "not_initialized"}
+ return {"success": False, "error": "not_implemented"}
+
diff --git a/api/app/routers/admin_marketplace.py b/api/app/routers/admin_marketplace.py
new file mode 100644
index 00000000..f4aff289
--- /dev/null
+++ b/api/app/routers/admin_marketplace.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import os
+from typing import Optional
+from fastapi import APIRouter, Depends, Header, HTTPException
+from api.app.config import settings
+from api.app.auth import verify_db_key
+
+
+def require_admin(x_api_key: Optional[str] = Header(default=None)):
+ """Minimal admin auth dependency for marketplace endpoints.
+ - Accepts env bootstrap API key
+ - Or a DB-backed key with scope keys:manage
+ """
+ if settings.api_key and x_api_key == settings.api_key:
+ return {"admin": True, "key_id": "env"}
+ info = verify_db_key(x_api_key)
+ if not info or ("keys:manage" not in (info.get("scopes") or [])):
+ raise HTTPException(401, detail="Admin authentication failed")
+ return info
+
+
+router = APIRouter(prefix="/admin/marketplace", tags=["AdminMarketplace"], dependencies=[Depends(require_admin)])
+
+
+@router.get("/plugins")
+def list_plugins():
+ """List available plugins in the marketplace (disabled by default).
+
+ Gate with ADMIN_MARKETPLACE_ENABLED=false by default to avoid exposing in prod
+ until features are complete.
+ """
+ enabled = os.getenv("ADMIN_MARKETPLACE_ENABLED", "false").lower() in {"1", "true", "yes"}
+ if not enabled:
+ # Hide endpoint when disabled to avoid confusing operators
+ raise HTTPException(404, detail="Not found")
+ # Placeholder: will be populated via trait‑aware registry in later PRs
+ return {"plugins": []}
+