/* global React */ // ======================================================================= // BlazeConnector Admin v3 — Administración pages // Tenants (+ detail) · API Keys · Templates · Auditoría // ======================================================================= const { useState: _as, useEffect: _ae, useMemo: _am, useRef: _ar } = React; /* ========================================================================= TENANTS — list ========================================================================= */ function PageTenants({ onOpenTenant, role }) { const bc = window.useBC(); const TS = bc.tenants; const live = bc.mode === 'live'; const [search, setSearch] = _as(''); const [planF, setPlanF] = _as('all'); const [statusF, setStatusF] = _as('all'); const [creating, setCreating] = _as(false); const rows = _am(() => { const ql = search.toLowerCase(); return TS.filter(t => { if (planF !== 'all' && t.plan !== planF) return false; if (statusF !== 'all' && t.status !== statusF) return false; if (ql && !(t.name + ' ' + t.id + ' ' + t.uuid + ' ' + (t.region || '')).toLowerCase().includes(ql)) return false; return true; }); }, [search, planF, statusF, TS]); const loading = bc.tenantsLoading && TS.length === 0; const failed = bc.status === 'error' && TS.length === 0; return (

Tenants {live && live}

{rows.length} de {TS.length} ISPs · {TS.filter(t => t.status === 'active').length} activos · {TS.filter(t => t.status === 'suspended').length} suspendidos

{window.hasPerm(role, 'tenant:edit') && }
a + (t.msgs24h || 0), 0)} unit="" color="orange" delta={live ? null : 4.2} series={live ? null : window.DASHBOARD_SERIES.msgsPerMin.slice(0, 30)} foot={live ? 'no expuesto por admin API' : null} /> a + (t.subs || 0), 0)} color="blue" foot={live ? 'no expuesto por admin API' : 'end-users servidos'} /> t.status === 'active').length} color="green" foot={'de ' + TS.length + ' tenants'} />
{['all','Enterprise','Pro','Starter'].map(p => ( ))} {['all','active','suspended','inactive'].map(s => ( ))} {loading ? : failed ? window.BC.loadTenants()}>Reintentar} /> : onOpenTenant(r.id)} columns={[ { label: 'Tenant', render: (t) => (
{t.name}
{t.uuid}
) }, { label: 'Plan', width: 110, render: (t) => {t.plan} }, { label: 'Estado', width: 100, render: (t) => }, { label: 'Canales', width: 120, render: (t) => t.channels.length ?
{t.channels.map(c => )}
: }, { label: 'Billing', width: 110, render: (t) => {t.billing || '—'} }, { label: 'Módulos', width: 70, numeric: true, render: (t) => {t.modules.length} }, { label: 'Pagos', width: 90, render: (t) => {t.pagos.length ? t.pagos.join(', ') : '—'} }, { label: 'Actualizado', width: 130, render: (t) => hace {window.timeAgo(t.lastActive)} }, { label: '', width: 40, render: () => } ]} rows={rows} />}
{creating && setCreating(false)} onCreated={(uuid) => { setCreating(false); if (uuid) onOpenTenant(uuid); }} />}
); } /* Crear tenant — POST /admin/tenants real en modo live */ function CreateTenantModal({ live, onClose, onCreated }) { const [name, setName] = _as(''); const [plan, setPlan] = _as('Pro'); const [busy, setBusy] = _as(false); const [err, setErr] = _as(null); const PLAN_API = { Starter: 'free', Pro: 'pro', Enterprise: 'enterprise' }; async function submit() { if (!name.trim()) { setErr('El nombre es obligatorio'); return; } if (!live) { onCreated(null); return; } setBusy(true); setErr(null); try { const t = await window.BC.createTenant({ name: name.trim(), plan: PLAN_API[plan] }); onCreated(t && t.uuid); } catch (e) { setErr(e.message); setBusy(false); } } return ( }>
Plan
{['Starter', 'Pro', 'Enterprise'].map(p => )}
{err &&
{err}
}
Los módulos y proveedores se configuran luego desde el detalle del tenant.
); } /* ========================================================================= TENANT DETAIL ========================================================================= */ function PageTenantDetail({ tenantId, onBack, role }) { const bc = window.useBC(); const live = bc.mode === 'live'; const tenant = bc.tenants.find(t => t.id === tenantId); const [tab, setTab] = _as('overview'); if (!tenant) return
; const liveKeys = (bc.keysByTenant[tenant.uuid] || {}).data; const liveTpls = (bc.templatesByTenant[tenant.uuid] || {}).data; return (

{tenant.name}

{tenant.id} · {tenant.uuid} · {tenant.region}

{window.hasPerm(role, 'tenant:edit') && (tenant.status === 'active' ? : )} {window.hasPerm(role, 'tenant:edit') && }
{[ { id: 'overview', label: 'Overview' }, { id: 'providers', label: 'Providers', count: tenant.pagos.length + (tenant.billing ? 1 : 0) }, { id: 'modules', label: 'Módulos', count: tenant.modules.length }, { id: 'apikeys', label: 'API Keys', count: live ? (liveKeys ? liveKeys.length : null) : window.API_KEYS.filter(k => k.tenant === tenant.id).length }, { id: 'templates', label: 'Templates', count: live ? (liveTpls ? liveTpls.length : null) : window.TEMPLATES.length }, { id: 'audit', label: 'Audit', count: live ? null : window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id).length } ].map(t => ( ))}
{tab === 'overview' && } {tab === 'providers' && } {tab === 'modules' && } {tab === 'apikeys' && } {tab === 'templates' && } {tab === 'audit' && }
); } function TenantOverview({ tenant }) { const series = window.DASHBOARD_SERIES.msgsPerMin.map((v) => Math.round(v * (tenant.msgs24h / 60000))); return ( <>
2 ? 'danger' : tenant.errorRate > 1 ? 'warn' : 'green'} delta={tenant.errorRate > 2 ? 240 : -8} deltaTone={tenant.errorRate > 2 ? 'down' : 'up'} foot="últimos 60m" />
Tenant ID
{tenant.id}
UUID
{tenant.uuid}
Slug
{tenant.slug}.blaze.do
Plan
{tenant.plan}
Región
{tenant.region}
Estado
Onboarded
12 ago 2024
Última actividad
hace {window.timeAgo(tenant.lastActive)}
{window.fmtDateTime(a.ts)} }, { label: 'Actor', width: 200, render: (a) => {a.actor.type === 'user' ? : }{a.actor.name} }, { label: 'Acción', width: 200, render: (a) => {a.action} }, { label: 'Target', render: (a) => {a.target} } ]} rows={window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id || !a.tenant).slice(0, 6)} /> ); } function TenantProviders({ tenant }) { const billing = window.PROVIDERS_ISP.find(p => p.id === tenant.billing); const pagos = window.PROVIDERS_PAY.filter(p => tenant.pagos.includes(p.id)); return (
{billing ? (
{billing.name[0]}
{billing.name}
{billing.version} · {billing.apiBase}
Latencia avg
{billing.latency}ms
Tenants compartidos
{billing.tenants}
Auth
OAuth2 client_credentials
Webhook URL
{'https://api.blaze.do/v3/webhooks/billing/' + tenant.slug}
Última sync
hace 4m
) : }
{pagos.length === 0 ? : (
{pagos.map(p => (
{p.name.slice(0, 2).toUpperCase()}
{p.name}
{p.currency} · {p.authMode} · {p.latency || '—'}ms
))}
)}
{tenant.channels.map(c => (
{window.CHANNEL_LABEL[c]}
{c === 'whatsapp' ? 'phone_number_id=104982304' : c === 'telegram' ? 'bot @' + tenant.slug + 'bot' : 'workspace #' + tenant.slug + '-soporte'}
))}
POST https://erp.{tenant.slug}.com/webhooks/blaze/pagos · 24h: 1,842 · ok
POST https://crm.{tenant.slug}.com/webhooks/blaze/mensajes · 24h: 8,412 · ok
POST https://staging.{tenant.slug}.com/hook · 24h: 142 · 4 fallos
); } // Módulos reales del backend (tenant.ModuleFlags) con etiqueta + icono. const BACKEND_MODULES = [ { id: 'wacloud', name: 'WhatsApp Cloud', desc: 'Envío/recepción vía Meta Cloud API', icon: 'message-square' }, { id: 'telegram', name: 'Telegram', desc: 'Bot de Telegram', icon: 'send' }, { id: 'chatwoot', name: 'Chatwoot', desc: 'Bridge con inbox Chatwoot', icon: 'messages-square' }, { id: 'mikrowisp', name: 'Mikrowisp', desc: 'Billing/OSS Mikrowisp', icon: 'router' }, { id: 'wisphub', name: 'WispHub', desc: 'Billing/OSS WispHub', icon: 'router' }, { id: 'domiisp', name: 'DomiISP', desc: 'Billing/OSS DomiISP', icon: 'router' }, { id: 'oficable', name: 'Oficable', desc: 'Billing/OSS Oficable', icon: 'router' }, { id: 'smartolt', name: 'SmartOLT', desc: 'Gestión de OLTs', icon: 'network' }, { id: 'voltage', name: 'Voltage', desc: 'Monitoreo de voltaje', icon: 'zap' }, { id: 'cardnet', name: 'CardNET', desc: 'Pasarela de pagos CardNET', icon: 'credit-card' }, { id: 'azul', name: 'Azul', desc: 'Pasarela Azul (Popular)', icon: 'credit-card' }, { id: 'paypal', name: 'PayPal', desc: 'Pasarela PayPal', icon: 'credit-card' }, { id: 'cybersource', name: 'CyberSource', desc: 'Procesador Visa/CyberSource', icon: 'credit-card' }, { id: 'monitoring', name: 'Monitoring', desc: 'Ingesta de alertas NMS (PRTG…)', icon: 'activity' }, { id: 'blazechat', name: 'BlazeChat', desc: 'Chat interno Blaze', icon: 'message-circle' }, { id: 'blazeteams', name: 'BlazeTeams', desc: 'Notificaciones a equipos', icon: 'users' }, { id: 'blazebot', name: 'BlazeBot', desc: 'Agente/bot Blaze', icon: 'bot' } ]; function TenantModules({ tenant }) { const live = tenant._live === true; const [saving, setSaving] = _as(null); // id del módulo guardando const [err, setErr] = _as(null); // Demo: catálogo cosmético original; Live: flags reales del backend. if (!live) { const all = [ { id: 'mensajeria', name: 'Mensajería', desc: 'Inbox unificado, plantillas WhatsApp, broadcasts', icon: 'message-square' }, { id: 'billing', name: 'Billing sync', desc: 'Sincroniza clientes, facturas y servicios con el OSS', icon: 'file-text' }, { id: 'pagos', name: 'Pagos', desc: 'Procesa pagos online y reconcilia con el OSS', icon: 'credit-card' }, { id: 'automation', name: 'Automation', desc: 'Workflows: corte automático, recordatorios, escalado', icon: 'workflow' }, { id: 'ai_agent', name: 'AI Agent', desc: 'Agente IA para soporte L1 (BlazeAI)', icon: 'sparkles' }, { id: 'campaigns', name: 'Campañas', desc: 'Envío masivo segmentado (requiere plan Pro)', icon: 'megaphone' } ]; return
{all.map(m => )}
; } const flags = tenant.modulesRaw || {}; async function toggle(id) { setSaving(id); setErr(null); try { const next = Object.assign({}, flags, { [id]: !flags[id] }); await window.BC.updateModules(tenant.uuid, next); } catch (e) { setErr(e.message); } finally { setSaving(null); } } return ( <> {err &&
{err}
}
{BACKEND_MODULES.map(m => toggle(m.id)} />)}
); } function ModuleCard({ m, on, saving, onToggle }) { return (
{m.name} {saving ? : }
{m.desc}
); } function Toggle({ on }) { return ( ); } // Scopes reales del control plane (admin:* = wildcard). const SCOPE_OPTIONS = ['messaging:read', 'messaging:write', 'payments:read', 'payments:write', 'isp:read', 'isp:write', 'billing:read', 'network:read', 'blazehub:read', 'blazehub:write', 'admin:*']; function TenantApiKeys({ tenant, role }) { const bc = window.useBC(); const live = tenant._live === true; const [issuing, setIssuing] = _as(false); const [rawKey, setRawKey] = _as(null); const [revoking, setRevoking] = _as(null); // Demo: keys mock filtradas. Live: carga real on-mount. _ae(() => { if (live && !bc.keysByTenant[tenant.uuid]) window.BC.loadKeys(tenant.uuid).catch(() => {}); }, [live, tenant.uuid]); const slot = live ? (bc.keysByTenant[tenant.uuid] || { loading: true, data: [] }) : { loading: false, data: window.API_KEYS.filter(k => k.tenant === tenant.id) }; const keys = slot.data || []; const canCreate = window.hasPerm(role, 'apikey:create'); const columns = live ? keyColumnsLive((k) => setRevoking(k)) : ApiKeyColumns; return ( setIssuing(true)}> Emitir key}> {slot.loading ? : slot.error ? : keys.length === 0 ? : } {issuing && setIssuing(false)} onIssued={(d) => { setIssuing(false); setRawKey(d); }} />} {rawKey && setRawKey(null)} />} {revoking && setRevoking(null)} onConfirm={() => window.BC.revokeKey(tenant.uuid, revoking.id).catch(() => {})} />} ); } // Columnas para keys reales (sin IP/preview inventados; revocar real). function keyColumnsLive(onRevoke) { return [ { label: 'Key', render: (k) => (
{k.name}
{k.preview}
) }, { label: 'Scopes', render: (k) =>
{k.scopes.map(s => {s})}
}, { label: 'Creada', width: 130, render: (k) => hace {window.timeAgo(k.createdAt)} }, { label: 'Último uso', width: 130, render: (k) => hace {window.timeAgo(k.lastUsed)} }, { label: 'Estado', width: 100, render: (k) => }, { label: '', width: 90, render: (k) => (
{k.status === 'active' && }
) } ]; } // Modal de emisión real (POST /admin/tenants/:uuid/apikeys). // Si `tenant` es null y se pasa `tenants`, muestra selector de tenant (vista global). function IssueKeyModal({ tenant, tenants, live, onClose, onIssued }) { const [name, setName] = _as(''); const [scopes, setScopes] = _as(['messaging:read', 'messaging:write']); const [selUuid, setSelUuid] = _as(tenant ? tenant.uuid : ((tenants && tenants[0] && tenants[0].uuid) || '')); const [busy, setBusy] = _as(false); const [err, setErr] = _as(null); function toggleScope(s) { setScopes(p => p.includes(s) ? p.filter(x => x !== s) : [...p, s]); } async function submit() { const uuid = tenant ? tenant.uuid : selUuid; if (!uuid) { setErr('Selecciona un tenant'); return; } if (!name.trim() || scopes.length === 0) { setErr('Nombre y al menos un scope'); return; } if (!live) { onIssued({ raw_token: 'bk_demo_' + window.randHex(20), name: name.trim(), scopes, created_at: new Date().toISOString() }, uuid); return; } setBusy(true); setErr(null); try { onIssued(await window.BC.issueKey(uuid, { name: name.trim(), scopes }), uuid); } catch (e) { setErr(e.message); setBusy(false); } } return ( }>
{!tenant && tenants && ( )}
Scopes ({scopes.length})
{SCOPE_OPTIONS.map(s => )}
{err &&
{err}
}
); } // Muestra el token RAW una sola vez (raw_token de la respuesta real). function RawKeyModal({ data, tenant, onClose }) { const raw = data.raw_token || data.raw || ''; return ( }>
No podemos recuperar esta key más adelante. Guárdala en tu gestor de secretos.
Token
Nombre
{data.name}
{tenant && <>
Tenant
{tenant.name}
}
Scopes
{(data.scopes || []).join(', ')}
); } function TenantTemplates({ tenant }) { const bc = window.useBC(); const live = tenant._live === true; const [syncing, setSyncing] = _as(false); _ae(() => { if (live && !bc.templatesByTenant[tenant.uuid]) window.BC.loadTemplates(tenant.uuid).catch(() => {}); }, [live, tenant.uuid]); const slot = live ? (bc.templatesByTenant[tenant.uuid] || { loading: true, data: [] }) : { loading: false, data: window.TEMPLATES }; const tpls = slot.data || []; async function sync() { setSyncing(true); try { await window.BC.syncTemplates(tenant.uuid); } catch (e) {} finally { setSyncing(false); } } return ( {syncing ? 'Sincronizando…' : 'Sync ahora'}}> {slot.loading ? : slot.error ? : tpls.length === 0 ? : {t.name} }, { label: 'Categoría', width: 140, render: (t) => {t.category} }, { label: 'Idioma', width: 80, render: (t) => {t.lang} }, { label: 'Estado', width: 130, render: (t) => } ]} rows={tpls} />} ); } function TenantAudit({ tenant }) { const events = window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id); return ( {events.length === 0 ? : } ); } /* ========================================================================= API KEYS — global ========================================================================= */ const ApiKeyColumns = [ { label: 'Key', render: (k) => (
{k.name}
{k.preview}
) }, { label: 'Tenant', width: 160, render: (k) => {k.tenantName} }, { label: 'Scopes', render: (k) =>
{k.scopes.map(s => {s})}
}, { label: 'Última IP', width: 130, render: (k) => {k.lastIp} }, { label: 'Último uso', width: 130, render: (k) => hace {window.timeAgo(k.lastUsed)} }, { label: 'Estado', width: 100, render: (k) => }, { label: '', width: 80, render: (k) => (
{k.status === 'active' && }
) } ]; function PageApiKeys({ tenant, role }) { const bc = window.useBC(); const live = bc.mode === 'live'; const [issuing, setIssuing] = _as(false); const [rawKey, setRawKey] = _as(null); const [revoking, setRevoking] = _as(null); const [search, setSearch] = _as(''); // Tenants en alcance (uno o todos). const scope = _am(() => tenant.id === '*' ? bc.tenants : bc.tenants.filter(t => t.id === tenant.id), [tenant, bc.tenants]); // Live: carga keys de cada tenant en alcance (una vez). _ae(() => { if (!live) return; scope.forEach(t => { if (!bc.keysByTenant[t.uuid]) window.BC.loadKeys(t.uuid).catch(() => {}); }); }, [live, scope]); const rows = _am(() => { const ql = search.toLowerCase(); let all; if (live) { all = []; scope.forEach(t => { (bc.keysByTenant[t.uuid] || {}).data && (bc.keysByTenant[t.uuid].data).forEach(k => all.push(k)); }); } else { all = window.API_KEYS.filter(k => tenant.id === '*' || k.tenant === tenant.id); } return all.filter(k => !ql || (k.name + ' ' + k.tenantName + ' ' + k.id).toLowerCase().includes(ql)); }, [live, scope, search, bc.keysByTenant]); const loading = live && scope.some(t => (bc.keysByTenant[t.uuid] || {}).loading) && rows.length === 0; const columns = live ? [ { label: 'Key', render: (k) =>
{k.name}
{k.preview}
}, { label: 'Tenant', width: 180, render: (k) => {k.tenantName} }, { label: 'Scopes', render: (k) =>
{k.scopes.map(s => {s})}
}, { label: 'Último uso', width: 120, render: (k) => hace {window.timeAgo(k.lastUsed)} }, { label: 'Estado', width: 90, render: (k) => }, { label: '', width: 90, render: (k) => k.status === 'active' ?
: null } ] : ApiKeyColumns; return (

API Keys {live && live}

{rows.length} keys · {rows.filter(k => k.status === 'active').length} activas · {rows.filter(k => k.status === 'revoked').length} revocadas{tenant.id !== '*' && ' · ' + tenant.name}

{window.hasPerm(role, 'apikey:create') && }
Las keys solo se muestran una vez al emitirlas. Cópiala a tu gestor de secretos antes de cerrar el modal — no hay forma de recuperarla luego.
{loading ? : }
{issuing && setIssuing(false)} onIssued={(d) => { setIssuing(false); setRawKey(d); }} />} {rawKey && setRawKey(null)} />} {revoking && setRevoking(null)} onConfirm={() => window.BC.revokeKey(revoking.tenant, revoking.id).catch(() => {})} />}
); } /* ========================================================================= TEMPLATES (global) ========================================================================= */ function PageTemplates({ tenant }) { return (

Templates WhatsApp

{window.TEMPLATES.length} plantillas · sincronizadas con Meta Business Manager

t.status === 'APPROVED').length} color="green" /> t.status === 'PENDING').length} color="warn" /> t.status === 'REJECTED').length} color="danger" foot="revisa políticas de Meta" />
{}} placeholder="Buscar plantilla…" />
{t.name}
{t.name === 'factura_lista_ES' ? 'Su factura de {{1}} está lista. Monto: {{2}}. Vence el {{3}}.' : t.name === 'pago_recibido_ES' ? 'Confirmamos su pago de {{1}}. Gracias.' : 'Plantilla operacional'}
}, { label: 'Categoría', width: 140, render: (t) => {t.category} }, { label: 'Idioma', width: 80, render: (t) => {t.lang} }, { label: 'Estado', width: 130, render: (t) => }, { label: 'Tenants usando', width: 130, numeric: true, render: () => {Math.floor(Math.random() * 8) + 1} }, { label: 'Envíos 24h', width: 100, numeric: true, render: () => {(Math.floor(Math.random() * 5000) + 200).toLocaleString()} }, { label: 'Última sync', width: 130, render: () => hace {Math.floor(Math.random() * 30) + 1}m } ]} rows={window.TEMPLATES} />
); } /* ========================================================================= AUDITORÍA ========================================================================= */ const ACTION_TONE = { 'tenant.created': { tone: 'success', ico: 'plus' }, 'tenant.updated': { tone: 'info', ico: 'pencil' }, 'apikey.created': { tone: 'success', ico: 'key-round' }, 'apikey.revoked': { tone: 'danger', ico: 'key-round' }, 'config.updated': { tone: 'info', ico: 'settings' }, 'template.synced': { tone: 'info', ico: 'refresh-cw' }, 'template.rejected': { tone: 'danger', ico: 'x' }, 'retry.executed': { tone: 'warn', ico: 'rotate-cw' }, 'message.viewed': { tone: 'info', ico: 'eye' } }; function AuditTable({ events }) { return ( {window.fmtDateTime(a.ts)} }, { label: 'Acción', width: 240, render: (a) => { const c = ACTION_TONE[a.action] || { tone: 'neutral', ico: 'circle' }; return {a.action} ; } }, { label: 'Actor', width: 220, render: (a) => ( {a.actor.type === 'user' ? : } {a.actor.name} {a.actor.role && · {a.actor.role}} ) }, { label: 'Target', render: (a) => {a.target} }, { label: 'Tenant', width: 160, render: (a) => a.tenant ? t.id === a.tenant) || {}).name || a.tenant }} size={16} /> {(window.TENANTS.find(t => t.id === a.tenant) || {}).name || a.tenant} : global }, { label: 'Detalles', render: (a) => {Object.entries(a.meta).slice(0, 2).map(([k, v]) => k + '=' + (Array.isArray(v) ? v.length + ' items' : String(v))).join(' · ')} } ]} rows={events} /> ); } function PageAudit({ tenant }) { const [search, setSearch] = _as(''); const rows = _am(() => { const ql = search.toLowerCase(); return window.AUDIT_EVENTS.filter(a => { if (tenant.id !== '*' && a.tenant && a.tenant !== tenant.id) return false; if (ql && !(a.action + ' ' + a.actor.name + ' ' + a.target).toLowerCase().includes(ql)) return false; return true; }); }, [search, tenant]); return (

Auditoría

Registro inmutable · {rows.length} eventos · retención 365 días

); } Object.assign(window, { PageTenants, PageTenantDetail, PageApiKeys, PageTemplates, PageAudit, AuditTable });