/* global React */ // ======================================================================= // BlazeConnector Admin v3 — Cliente API real + store de datos "live" // ----------------------------------------------------------------------- // Conecta el UI a la API admin real de BlazeConnector usando X-Admin-Token. // · modo "demo" → usa los mocks de data.jsx (sin backend) // · modo "live" → datos reales del backend (GET/POST/PUT/DELETE /admin/*) // El store usa React.useSyncExternalStore (React 18.3.1) para que cualquier // página se re-renderice cuando cambian los datos. window.TENANTS se mantiene // sincronizado para los consumidores que aún leen el global (TenantSwitcher, // routing en app.jsx). // ======================================================================= const BC_CFG_KEY = 'bc_admin_cfg_v1'; // Snapshot de los mocks originales (data.jsx ya corrió) para el modo demo. const DEMO_TENANTS = (window.TENANTS || []).slice(); const DEMO_KEYS = (window.API_KEYS || []).slice(); const DEMO_TEMPLATES = (window.TEMPLATES || []).slice(); // ---- Config persistente (localStorage) ------------------------------- function loadCfg() { try { return JSON.parse(localStorage.getItem(BC_CFG_KEY) || 'null'); } catch (e) { return null; } } function saveCfg(cfg) { try { localStorage.setItem(BC_CFG_KEY, JSON.stringify(cfg)); } catch (e) {} } function clearCfg() { try { localStorage.removeItem(BC_CFG_KEY); } catch (e) {} } // ---- External store -------------------------------------------------- const _listeners = new Set(); let _state = { mode: 'demo', // 'demo' | 'live' status: 'disconnected', // 'disconnected' | 'connecting' | 'connected' | 'error' demoChosen: false, // true cuando el usuario eligió explícitamente modo demo error: null, baseUrl: '', tenants: DEMO_TENANTS, tenantsLoading: false, keysByTenant: {}, // uuid -> { loading, error, data:[] } templatesByTenant: {} // uuid -> { loading, error, data:[] } }; function getSnapshot() { return _state; } function subscribe(cb) { _listeners.add(cb); return () => _listeners.delete(cb); } function setState(patch) { _state = Object.assign({}, _state, patch); // Mantener el global sincronizado para consumidores no migrados al hook. if (patch.tenants) window.TENANTS = patch.tenants; _listeners.forEach((l) => l()); } function useBC() { return React.useSyncExternalStore(subscribe, getSnapshot); } // ---- HTTP helper ----------------------------------------------------- async function req(method, path, body) { const cfg = loadCfg(); if (!cfg) throw new Error('Sin configuración de conexión'); const res = await fetch(cfg.baseUrl + path, { method, headers: Object.assign( { 'X-Admin-Token': cfg.token }, body ? { 'Content-Type': 'application/json' } : {} ), body: body ? JSON.stringify(body) : undefined }); let json = null; try { json = await res.json(); } catch (e) { /* respuesta sin body */ } if (!res.ok || (json && json.error)) { const msg = (json && (json.message || json.code)) || ('HTTP ' + res.status); const err = new Error(msg); err.status = res.status; throw err; } return json || {}; } // ---- Adapters: shape real del backend → shape que esperan las páginas - function slugify(s) { return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } // modules{} (bools del backend) → canales / billing / pagos del UI const MOD_CHANNEL = { wacloud: 'whatsapp', telegram: 'telegram', chatwoot: 'chatwoot' }; const MOD_BILLING = ['mikrowisp', 'wisphub', 'domiisp', 'oficable', 'smartolt', 'voltage']; const MOD_PAGOS = ['cardnet', 'azul', 'paypal', 'cybersource']; const PLAN_LABEL = { free: 'Starter', pro: 'Pro', enterprise: 'Enterprise' }; function adaptTenant(t) { const mods = t.modules || {}; const activeMods = Object.keys(mods).filter((k) => mods[k]); return { id: t.uuid, // routing y avatares usan .id → usamos el uuid real uuid: t.uuid, name: t.name, slug: slugify(t.name), plan: PLAN_LABEL[t.plan] || (t.plan ? t.plan[0].toUpperCase() + t.plan.slice(1) : '—'), status: t.status, modules: activeMods, // array → .length funciona en la lista modulesRaw: mods, // objeto del backend → para la pestaña Módulos region: '—', // la API admin no expone región channels: Object.keys(MOD_CHANNEL).filter((k) => mods[k]).map((k) => MOD_CHANNEL[k]), billing: MOD_BILLING.find((k) => mods[k]) || null, pagos: MOD_PAGOS.filter((k) => mods[k]), // Métricas no expuestas por la API admin → 0/—; honestas, no inventadas. subs: 0, msgs24h: 0, errorRate: 0, lastActive: t.updated_at || t.created_at, createdAt: t.created_at, _live: true }; } // La struct apikey.APIKey se serializa en snake_case; HashedKey lleva json:"-" // (jamás llega al cliente). Tolera PascalCase por compatibilidad con backends viejos. function adaptKey(k, tenantName) { const id = k.id || k.ID; const tenantId = k.tenant_id || k.TenantID; return { id, name: k.name || k.Name, tenant: tenantId, tenantName: tenantName || tenantId, scopes: k.scopes || k.Scopes || [], status: (k.status || k.Status || '').toLowerCase(), createdAt: k.created_at || k.CreatedAt, lastUsed: k.last_used_at || k.LastUsedAt || k.created_at || k.CreatedAt, lastIp: '—', preview: 'bk_' + (k.prefix || k.Prefix || '????????') + '_••••••••' }; } function adaptTemplate(t) { // Shape defensivo: el registry puede variar de claves. return { name: t.name || t.Name || '(sin nombre)', category: (t.category || t.Category || 'UTILITY').toUpperCase(), lang: t.language || t.lang || t.Language || 'es', status: (t.status || t.Status || 'PENDING').toUpperCase() }; } // ---- Cliente público ------------------------------------------------- const BC = { isConfigured() { return !!loadCfg(); }, getConfig() { return loadCfg(); }, setConfig(baseUrl, token) { saveCfg({ baseUrl: (baseUrl || '').replace(/\/+$/, ''), token }); }, // Prueba de conexión SIN persistir (para el botón "Probar"). async test(baseUrl, token) { const res = await fetch((baseUrl || '').replace(/\/+$/, '') + '/admin/tenants?limit=1', { headers: { 'X-Admin-Token': token } }); if (!res.ok) { const e = new Error(res.status === 401 || res.status === 403 ? 'Token admin inválido' : 'HTTP ' + res.status); e.status = res.status; throw e; } const j = await res.json(); return Array.isArray(j.data) ? j.data.length : 0; }, async connect(baseUrl, token) { this.setConfig(baseUrl, token); setState({ mode: 'live', status: 'connecting', error: null, baseUrl: loadCfg().baseUrl }); try { await this.loadTenants(); setState({ status: 'connected' }); } catch (e) { setState({ status: 'error', error: e.message }); throw e; } }, // Reconecta usando config persistida (al recargar la página). async reconnect() { const cfg = loadCfg(); if (!cfg) return false; setState({ mode: 'live', status: 'connecting', baseUrl: cfg.baseUrl, error: null }); try { await this.loadTenants(); setState({ status: 'connected' }); return true; } catch (e) { setState({ status: 'error', error: e.message }); return false; } }, // Usuario elige modo demo → oculta el gate, usa mocks. demo() { setState({ mode: 'demo', status: 'disconnected', demoChosen: true, error: null, tenants: DEMO_TENANTS, keysByTenant: {}, templatesByTenant: {} }); }, // Cierra la sesión live y REABRE el gate de conexión. disconnect() { clearCfg(); setState({ mode: 'demo', status: 'disconnected', demoChosen: false, error: null, tenants: DEMO_TENANTS, keysByTenant: {}, templatesByTenant: {} }); }, async loadTenants() { setState({ tenantsLoading: true }); try { const j = await req('GET', '/admin/tenants?limit=500'); const tenants = (j.data || []).map(adaptTenant); setState({ tenants, tenantsLoading: false }); return tenants; } catch (e) { setState({ tenantsLoading: false }); throw e; } }, async loadKeys(uuid) { const tName = (getSnapshot().tenants.find((t) => t.uuid === uuid) || {}).name; const set = (v) => setState({ keysByTenant: Object.assign({}, getSnapshot().keysByTenant, { [uuid]: v }) }); set({ loading: true, error: null, data: (getSnapshot().keysByTenant[uuid] || {}).data || [] }); try { const j = await req('GET', '/admin/tenants/' + uuid + '/apikeys'); const data = (j.data || []).map((k) => adaptKey(k, tName)); set({ loading: false, error: null, data }); return data; } catch (e) { set({ loading: false, error: e.message, data: [] }); throw e; } }, async issueKey(uuid, payload) { const j = await req('POST', '/admin/tenants/' + uuid + '/apikeys', payload); await this.loadKeys(uuid); // refresca la lista return j.data; // incluye raw_token (única vez) }, async revokeKey(uuid, keyId) { await req('DELETE', '/admin/tenants/' + uuid + '/apikeys/' + keyId); await this.loadKeys(uuid); }, async createTenant(payload) { const j = await req('POST', '/admin/tenants', payload); await this.loadTenants(); return j.data; }, async updateModules(uuid, modules) { await req('PUT', '/admin/tenants/' + uuid + '/modules', { modules }); await this.loadTenants(); }, async loadTemplates(uuid) { const set = (v) => setState({ templatesByTenant: Object.assign({}, getSnapshot().templatesByTenant, { [uuid]: v }) }); set({ loading: true, error: null, data: (getSnapshot().templatesByTenant[uuid] || {}).data || [] }); try { const j = await req('GET', '/admin/tenants/' + uuid + '/templates'); const data = (j.data || []).map(adaptTemplate); set({ loading: false, error: null, data }); return data; } catch (e) { set({ loading: false, error: e.message, data: [] }); throw e; } }, async syncTemplates(uuid) { await req('POST', '/admin/tenants/' + uuid + '/templates/sync'); await this.loadTemplates(uuid); } }; Object.assign(window, { BC, useBC, bcStore: { getSnapshot, subscribe, setState }, DEMO_TENANTS, DEMO_KEYS, DEMO_TEMPLATES });