diff --git a/api/admin_ui/src/App.tsx b/api/admin_ui/src/App.tsx
index c835827b..7cd8fc92 100644
--- a/api/admin_ui/src/App.tsx
+++ b/api/admin_ui/src/App.tsx
@@ -832,7 +832,7 @@ function AppContent() {
)}
{toolsTab === 2 && }
{toolsTab === 3 && }
- {toolsTab === 4 && }
+ {toolsTab === 4 && }
{toolsTab === 5 && }
{toolsTab === 6 && (
>([]);
+ const [disabled, setDisabled] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const run = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`${window.location.origin}/admin/marketplace/plugins`, {
+ headers: { 'X-API-Key': (client as any).apiKey || '' },
+ });
+ if (res.status === 404) {
+ if (!cancelled) setDisabled(true);
+ return;
+ }
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ if (!cancelled) setPlugins(Array.isArray(data?.plugins) ? data.plugins : []);
+ } catch {
+ if (!cancelled) setDisabled(true);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ run();
+ return () => { cancelled = true; };
+ }, [client]);
return (
@@ -32,14 +63,42 @@ export default function PluginMarketplace(): JSX.Element {
sx={{ mb: 3 }}
/>
+ {disabled && (
+
+ Plugin Marketplace is disabled. Enable by setting ADMIN_MARKETPLACE_ENABLED=true.
+ {docsBase && (
+ <>
+ {' '}Learn more at docs.
+ >
+ )}
+
+ )}
+
+ {loading && }
+
{/* Empty state placeholder; results will be populated in later PRs */}
-
- Marketplace results will appear here. Use traits to gate provider‑specific UI.
-
+ {plugins.length === 0 ? (
+
+ {disabled ? 'Marketplace is currently disabled.' : 'No plugins found.'}
+
+ ) : (
+ <>
+ {plugins
+ .filter(p => (query ? (p.name || '').toLowerCase().includes(query.toLowerCase()) : true))
+ .map(p => (
+
+ {p.name || p.id}
+ {p.description && (
+ {p.description}
+ )}
+
+ ))}
+ >
+ )}
@@ -47,4 +106,3 @@ export default function PluginMarketplace(): JSX.Element {
);
}
-
diff --git a/api/app/routers/admin_marketplace.py b/api/app/routers/admin_marketplace.py
index f4aff289..183e839e 100644
--- a/api/app/routers/admin_marketplace.py
+++ b/api/app/routers/admin_marketplace.py
@@ -37,3 +37,15 @@ def list_plugins():
# Placeholder: will be populated via trait‑aware registry in later PRs
return {"plugins": []}
+
+@router.post("/install")
+def install_plugin(payload: Dict[str, Any] | None = None):
+ """Remote install a plugin (disabled by default).
+
+ Gate with ADMIN_MARKETPLACE_REMOTE_INSTALL_ENABLED=false by default.
+ """
+ remote_enabled = os.getenv("ADMIN_MARKETPLACE_REMOTE_INSTALL_ENABLED", "false").lower() in {"1", "true", "yes"}
+ if not remote_enabled:
+ raise HTTPException(503, detail="Remote install disabled")
+ # Placeholder only; actual implementation will be added later
+ return {"ok": False, "message": "not_implemented"}
diff --git a/api/app/sinch_service.py b/api/app/sinch_service.py
index 41a5a60f..996f829f 100644
--- a/api/app/sinch_service.py
+++ b/api/app/sinch_service.py
@@ -3,6 +3,7 @@
import logging
import os
import time
+import anyio
from .config import settings, reload_settings
@@ -80,7 +81,9 @@ async def upload_file(self, file_path: str) -> int:
url = f"{base}/projects/{self.project_id}/files"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
- files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/pdf")}
+ async with await anyio.open_file(file_path, 'rb') as f:
+ content = await f.read()
+ files = {"file": (os.path.basename(file_path), content, "application/pdf")}
if self._use_oauth():
try:
token = await self.get_access_token()
@@ -154,20 +157,21 @@ async def send_fax_file(self, to_number: str, file_path: str) -> Dict[str, Any]:
to = f"+{digits}"
url = f"{self.base_url}/projects/{self.project_id}/faxes"
async with httpx.AsyncClient(timeout=60.0) as client:
- # httpx expects a mapping of field name → (filename, fileobj, content_type)
+ # httpx expects a mapping of field name → (filename, bytes, content_type)
# For the additional text field, pass as data not files
- with open(file_path, "rb") as fh:
- files = {"file": (os.path.basename(file_path), fh, "application/pdf")}
- data = {"to": to}
- if self._use_oauth():
- try:
- token = await self.get_access_token()
- resp = await client.post(url, files=files, data=data, headers={"Authorization": f"Bearer {token}"})
- except Exception as e:
- logger.warning("Sinch OAuth send_fax_file failed; falling back to Basic: %s", e)
- resp = await client.post(url, files=files, data=data, auth=self._basic_auth())
- else:
+ async with await anyio.open_file(file_path, 'rb') as fh:
+ content = await fh.read()
+ files = {"file": (os.path.basename(file_path), content, "application/pdf")}
+ data = {"to": to}
+ if self._use_oauth():
+ try:
+ token = await self.get_access_token()
+ resp = await client.post(url, files=files, data=data, headers={"Authorization": f"Bearer {token}"})
+ except Exception as e:
+ logger.warning("Sinch OAuth send_fax_file failed; falling back to Basic: %s", e)
resp = await client.post(url, files=files, data=data, auth=self._basic_auth())
+ else:
+ resp = await client.post(url, files=files, data=data, auth=self._basic_auth())
if resp.status_code >= 400:
raise RuntimeError(f"Sinch create fax error {resp.status_code}: {resp.text}")
return resp.json()