/* global React */ // ======================================================================= // BlazeConnector Admin v3 — Control plane (live) // · TenantProviders — editor REAL de provider configs (PUT /configs, merge) // · TenantHealthPanel — diagnóstico de completitud (GET /tenants/:id/health) // · PageServiceTokens — CRUD de service tokens (S2S: BlazeERP/BlazeOCR…) // Los secretos NUNCA llegan en claro: getConfigs los enmascara como "__SET__". // El editor manda secretos en blanco como "__SET__" → el backend preserva el // valor actual (merge). Ver internal/transport/http/handlers/admin/configs.go. // ======================================================================= const { useState: _cs, useEffect: _ce, useMemo: _cm } = React; const MASK = '__SET__'; // Esquema de campos por provider (espejo de tenant.ProviderConfigs en Go). // type: text | secret | bool | number | select. const PROVIDER_SCHEMA = { wacloud: { label: 'WhatsApp Cloud', group: 'Mensajería', icon: 'message-square', fields: [ { key: 'phone_number_id', label: 'Phone Number ID', type: 'text' }, { key: 'token', label: 'Access Token', type: 'secret' }, { key: 'business_id', label: 'Business ID', type: 'text' }, { key: 'verify_token', label: 'Verify Token (webhook)', type: 'secret' }, { key: 'app_secret', label: 'App Secret (HMAC)', type: 'secret' } ] }, telegram: { label: 'Telegram', group: 'Mensajería', icon: 'send', fields: [ { key: 'bot_token', label: 'Bot Token', type: 'secret' }, { key: 'default_chat_id', label: 'Default Chat ID', type: 'text' }, { key: 'secret_token', label: 'Secret Token (webhook header)', type: 'secret' } ] }, chatwoot: { label: 'Chatwoot', group: 'Mensajería', icon: 'messages-square', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'token', label: 'API Token', type: 'secret' }, { key: 'account_id', label: 'Account ID', type: 'text' }, { key: 'hmac_secret', label: 'HMAC Secret', type: 'secret' } ] }, mikrowisp: { label: 'Mikrowisp', group: 'Billing / OSS', icon: 'router', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'token', label: 'Token', type: 'secret' } ] }, wisphub: { label: 'WispHub', group: 'Billing / OSS', icon: 'router', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'token', label: 'Token', type: 'secret' } ] }, domiisp: { label: 'DomiISP', group: 'Billing / OSS', icon: 'router', fields: [ { key: 'api_url', label: 'API URL (api.php)', type: 'text' }, { key: 'token', label: 'X-API-Key', type: 'secret' } ] }, smartolt: { label: 'SmartOLT', group: 'Billing / OSS', icon: 'network', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'token', label: 'Token', type: 'secret' } ] }, oficable: { label: 'Oficable', group: 'Billing / OSS', icon: 'router', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'api_espejo', label: 'API Espejo (opcional)', type: 'text' }, { key: 'relay_enabled', label: 'Relay v3 activo', type: 'bool' }, { key: 'relay_cutoff', label: 'Relay cutoff (YYYY-MM-DD)', type: 'text' } ] }, voltage: { label: 'Voltage', group: 'Billing / OSS', icon: 'zap', fields: [ { key: 'low_threshold', label: 'Umbral bajo (V)', type: 'number' }, { key: 'high_threshold', label: 'Umbral alto (V)', type: 'number' }, { key: 'alert_phone', label: 'Teléfono alerta (E.164)', type: 'text' }, { key: 'alert_chat_id', label: 'Telegram chat ID', type: 'text' }, { key: 'min_interval_sec', label: 'Intervalo mínimo (s)', type: 'number' } ] }, cardnet: { label: 'CardNET', group: 'Pagos', icon: 'credit-card', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'merchant_id', label: 'Merchant ID', type: 'text' }, { key: 'terminal_id', label: 'Terminal ID', type: 'text' }, { key: 'api_key', label: 'API Key', type: 'secret' } ] }, azul: { label: 'Azul', group: 'Pagos', icon: 'credit-card', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'merchant_id', label: 'Merchant ID', type: 'text' }, { key: 'auth1', label: 'Auth1', type: 'secret' }, { key: 'auth2', label: 'Auth2', type: 'secret' } ] }, paypal: { label: 'PayPal', group: 'Pagos', icon: 'credit-card', fields: [ { key: 'client_id', label: 'Client ID', type: 'text' }, { key: 'client_secret', label: 'Client Secret', type: 'secret' }, { key: 'webhook_id', label: 'Webhook ID', type: 'text' }, { key: 'mode', label: 'Modo', type: 'select', options: ['sandbox', 'live'] } ] }, cybersource: { label: 'CyberSource', group: 'Pagos', icon: 'credit-card', fields: [ { key: 'merchant_id', label: 'Merchant ID', type: 'text' }, { key: 'api_key', label: 'API Key (Shared Secret)', type: 'secret' }, { key: 'secret_key', label: 'Secret Key (HTTP Sig)', type: 'secret' }, { key: 'mode', label: 'Modo', type: 'select', options: ['test', 'live'] }, { key: 'currency_default', label: 'Moneda default', type: 'text' } ] }, blazeteams: { label: 'BlazeTeams', group: 'Otros', icon: 'users', fields: [ { key: 'api_url', label: 'API URL', type: 'text' }, { key: 'token', label: 'Token (bt_…)', type: 'secret' }, { key: 'hmac_secret', label: 'HMAC Secret (webhook)', type: 'secret' } ] }, monitoring: { label: 'Monitoring (NMS)', group: 'Otros', icon: 'activity', fields: [ { key: 'inbound_token', label: 'Inbound Token (?token=)', type: 'secret' }, { key: 'min_interval_sec', label: 'Anti-spam (s)', type: 'number' } ] } }; const PROVIDER_GROUP_ORDER = ['Mensajería', 'Billing / OSS', 'Pagos', 'Otros']; // ---- Editor modal de un provider ------------------------------------- function ProviderConfigModal({ tenant, provider, masked, onClose, onSaved }) { const schema = PROVIDER_SCHEMA[provider]; const current = (masked && masked[provider]) || {}; const [form, setForm] = _cs(() => { const f = {}; schema.fields.forEach((fd) => { if (fd.type === 'secret') { f[fd.key] = ''; } // nunca pre-rellenamos secretos else if (fd.type === 'bool') { f[fd.key] = !!current[fd.key]; } else { f[fd.key] = current[fd.key] != null ? String(current[fd.key]) : ''; } }); return f; }); const [busy, setBusy] = _cs(false); const [err, setErr] = _cs(null); function setField(k, v) { setForm((p) => Object.assign({}, p, { [k]: v })); } async function submit() { setBusy(true); setErr(null); // Construye el sub-config del provider. Secretos en blanco → MASK (mantener). const out = {}; schema.fields.forEach((fd) => { const v = form[fd.key]; if (fd.type === 'secret') { out[fd.key] = v === '' ? MASK : v; } else if (fd.type === 'bool') { out[fd.key] = !!v; } else if (fd.type === 'number') { out[fd.key] = v === '' ? 0 : Number(v); } else { out[fd.key] = v; } }); try { await window.BC.updateConfigs(tenant.uuid, { [provider]: out }); onSaved && onSaved(); onClose(); } catch (e) { setErr(e.message); setBusy(false); } } return ( }>
Los secretos no se muestran. Deja un campo de secreto vacío para conservar el valor actual; escribe uno nuevo para reemplazarlo.
{schema.fields.map((fd) => { const isSet = current[fd.key] === MASK || (fd.type !== 'secret' && current[fd.key] != null && current[fd.key] !== ''); return ( ); })} {err &&
{err}
}
); } const inputStyle = { background: 'var(--app-input-bg)', border: '1px solid var(--app-border)', borderRadius: 6, padding: '8px 10px', color: 'var(--app-fg1)', fontFamily: 'inherit', fontSize: 13 }; const errStyle = { fontSize: 12, color: 'var(--st-danger-fg)', padding: '6px 8px', background: 'var(--st-danger-bg)', borderRadius: 6 }; // ---- Panel de salud (config completeness) ---------------------------- function TenantHealthPanel({ tenant }) { const bc = window.useBC(); const slot = bc.healthByTenant[tenant.uuid] || { loading: true, data: null }; const data = slot.data; if (!data) { return {slot.loading ? : } ; } const tone = data.status === 'healthy' ? 'success' : 'warn'; return ( }> {(!data.checks || data.checks.length === 0) ? :
{data.checks.map((ch) => (
{(PROVIDER_SCHEMA[ch.module] || {}).label || ch.module} {ch.ok ? (ch.note || 'completo') : (ch.note || ('faltan: ' + (ch.missing || []).join(', ')))} {!ch.enabled && ch.configured && módulo off}
))}
}
); } // ---- Tab Providers (editor real, modo live) -------------------------- function TenantProviders({ tenant }) { const bc = window.useBC(); const live = tenant._live === true; const [editing, setEditing] = _cs(null); _ce(() => { if (!live) return; if (!bc.configsByTenant[tenant.uuid]) window.BC.getConfigs(tenant.uuid).catch(() => {}); window.BC.loadTenantHealth(tenant.uuid).catch(() => {}); }, [live, tenant.uuid]); if (!live) { return Conéctate al backend real (botón de conexión) para ver y editar las credenciales de proveedores de este tenant. En modo demo solo se muestran datos de ejemplo. ; } const slot = bc.configsByTenant[tenant.uuid] || { loading: true, data: {}, present: {} }; const present = slot.present || {}; const masked = slot.data || {}; // Agrupa providers por sección para el render. const byGroup = {}; Object.keys(PROVIDER_SCHEMA).forEach((p) => { const g = PROVIDER_SCHEMA[p].group; (byGroup[g] = byGroup[g] || []).push(p); }); return (
window.BC.getConfigs(tenant.uuid)} /> {PROVIDER_GROUP_ORDER.map((g) => (
{(byGroup[g] || []).map((p) => { const sch = PROVIDER_SCHEMA[p]; const on = !!present[p]; return (
{sch.label}
{on ? 'configurado' : 'sin configurar'}
{on ? : }
); })}
))} {editing && setEditing(null)} onSaved={() => window.BC.loadTenantHealth(tenant.uuid).catch(() => {})} />}
); } // ======================================================================= // Service Tokens (control plane S2S) // ======================================================================= const ST_SCOPE_OPTIONS = ['tenants:read', 'events:read', 'events:read:global', 'webhooks:read', 'webhooks:write', 'audit:read']; function PageServiceTokens({ role }) { const bc = window.useBC(); const live = bc.mode === 'live'; const [issuing, setIssuing] = _cs(false); const [raw, setRaw] = _cs(null); const [revoking, setRevoking] = _cs(null); const [search, setSearch] = _cs(''); const canManage = window.hasPerm(role, 'apikey:create'); _ce(() => { if (live) window.BC.loadServiceTokens({ include_revoked: true }).catch(() => {}); }, [live]); if (!live) { return

Service Tokens

Credenciales S2S para sistemas internos (BlazeERP, BlazeOCR…)

Conéctate al backend real para emitir, listar y revocar service tokens del control plane.
; } const slot = bc.serviceTokens || { loading: true, data: [] }; const rows = (slot.data || []).filter((s) => { const ql = search.toLowerCase(); return !ql || (s.name + ' ' + s.serviceCode + ' ' + s.scopes.join(' ')).toLowerCase().includes(ql); }); return (

Service Tokens

{rows.length} tokens · {rows.filter((s) => s.status === 'active').length} activos · control plane S2S

{canManage && }
El secreto (bks_…) solo se muestra una vez al emitirlo. Estos tokens dan acceso al /service/v1/* de uno o más tenants — guárdalos en un gestor de secretos.
window.BC.loadServiceTokens({ include_revoked: true })} /> {slot.loading ? : rows.length === 0 ? :
{s.name}
{s.preview}
}, { label: 'Service', width: 140, render: (s) => {s.serviceCode} }, { label: 'Scopes', render: (s) =>
{s.scopes.map((x) => {x})}
}, { label: 'Tenants', width: 90, numeric: true, render: (s) => {s.allowedTenants && s.allowedTenants.length ? s.allowedTenants.length : '∞'} }, { label: 'Estado', width: 90, render: (s) => }, { label: '', width: 90, render: (s) => s.status === 'active' ?
: null } ]} rows={rows} />}
{issuing && setIssuing(false)} onIssued={(d) => { setIssuing(false); setRaw(d); }} />} {raw && setRaw(null)} />} {revoking && setRevoking(null)} onConfirm={() => window.BC.revokeServiceToken(revoking.id).catch(() => {})} />}
); } function IssueServiceTokenModal({ onClose, onIssued }) { const bc = window.useBC(); const [name, setName] = _cs(''); const [serviceCode, setServiceCode] = _cs(''); const [scopes, setScopes] = _cs(['tenants:read']); const [allowed, setAllowed] = _cs([]); // uuids; vacío = todos const [busy, setBusy] = _cs(false); const [err, setErr] = _cs(null); function toggleScope(s) { setScopes((p) => p.includes(s) ? p.filter((x) => x !== s) : [...p, s]); } function toggleTenant(uuid) { setAllowed((p) => p.includes(uuid) ? p.filter((x) => x !== uuid) : [...p, uuid]); } async function submit() { if (!name.trim() || !serviceCode.trim() || scopes.length === 0) { setErr('Nombre, service_code y al menos un scope son obligatorios'); return; } setBusy(true); setErr(null); try { const d = await window.BC.issueServiceToken({ name: name.trim(), service_code: serviceCode.trim(), scopes, allowed_tenants: allowed }); onIssued(d); } catch (e) { setErr(e.message); setBusy(false); } } return ( }>
Scopes ({scopes.length})
{ST_SCOPE_OPTIONS.map((s) => )}
Tenants permitidos ({allowed.length === 0 ? 'todos' : allowed.length})
{(bc.tenants || []).map((t) => )}
Vacío = acceso a todos los tenants.
{err &&
{err}
}
); } function RawServiceTokenModal({ data, onClose }) { const raw = data.raw_token || data.raw || ''; return ( }>
No podemos recuperar este secreto luego. Guárdalo en tu gestor de secretos.
Token
Nombre
{data.name}
Service
{data.service_code}
Scopes
{(data.scopes || []).join(', ')}
); } Object.assign(window, { TenantProviders, TenantHealthPanel, PageServiceTokens, PROVIDER_SCHEMA });