'use strict'; const App = (() => { // ── State ────────────────────────────────────────────────────────────────── let currentPage = 'dashboard'; let editingWebhookId = null; const PAGE_SIZE = 20; let receivedOffset = 0; let sentOffset = 0; // ── API helper ───────────────────────────────────────────────────────────── async function api(method, path, body) { const opts = { method, credentials: 'include', headers: { 'Content-Type': 'application/json' }, }; if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch('/api' + path, opts); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } // ── Toast ────────────────────────────────────────────────────────────────── function toast(msg, type = 'info') { const el = document.createElement('div'); el.className = `toast toast-${type}`; const icons = { success: '✅', error: '❌', info: 'ℹ️' }; el.innerHTML = `${icons[type] || ''}${msg}`; document.getElementById('toast-container').appendChild(el); setTimeout(() => el.remove(), 4000); } function showAlert(id, msg, type = 'danger') { const el = document.getElementById(id); if (!el) return; el.className = `alert alert-${type}`; el.textContent = msg; el.style.display = 'flex'; } function hideAlert(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; } // ── Formatters ───────────────────────────────────────────────────────────── function fmt(dateStr) { if (!dateStr) return '—'; try { return new Date(dateStr).toLocaleString(); } catch { return dateStr; } } function statusBadge(status) { const map = { sent: ['badge-blue', 'Sent'], received: ['badge-green', 'Received'], failed: ['badge-red', 'Failed'], pending: ['badge-yellow', 'Pending'], }; const [cls, label] = map[status] || ['badge-gray', status]; return `${label}`; } function esc(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ── Navigation ───────────────────────────────────────────────────────────── function navigate(page) { currentPage = page; document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); document.getElementById(`page-${page}`)?.classList.add('active'); document.querySelector(`.nav-item[data-page="${page}"]`)?.classList.add('active'); // Load data for page const loaders = { dashboard: loadDashboard, received: loadReceived, sent: loadSent, webhooks: loadWebhooks, tokens: loadTokens, settings: loadSettings, }; loaders[page]?.(); } // ── Auth ─────────────────────────────────────────────────────────────────── async function checkAuth() { try { const me = await api('GET', '/auth/me'); if (me.authenticated) { showApp(me.username); loadDashboard(); } else { showLogin(); } } catch { showLogin(); } } function showLogin() { document.getElementById('login-page').classList.add('active'); document.getElementById('sidebar').style.display = 'none'; document.getElementById('main').style.display = 'none'; } function showApp(username) { document.getElementById('login-page').classList.remove('active'); document.getElementById('sidebar').style.display = 'flex'; document.getElementById('main').style.display = 'flex'; document.getElementById('user-name').textContent = username || 'Admin'; document.getElementById('user-avatar').textContent = (username || 'A')[0].toUpperCase(); } async function login(e) { e.preventDefault(); const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; hideAlert('login-error'); try { await api('POST', '/auth/login', { username, password }); showApp(username); loadDashboard(); } catch (err) { showAlert('login-error', err.message || 'Login failed'); } } async function logout() { await api('POST', '/auth/logout'); showLogin(); } // ── Dashboard ────────────────────────────────────────────────────────────── async function loadDashboard() { try { const [stats, msgs] = await Promise.all([ api('GET', '/stats'), api('GET', '/sms/messages?limit=10'), ]); document.getElementById('stat-sent').textContent = stats.sent ?? 0; document.getElementById('stat-received').textContent = stats.received ?? 0; document.getElementById('stat-failed').textContent = stats.failed ?? 0; const tbody = document.getElementById('recent-messages'); if (!msgs.length) { tbody.innerHTML = '
📭
No messages yet
'; } else { tbody.innerHTML = msgs.map(m => ` ${m.direction === 'received' ? '📥' : '📤'} ${esc(m.phone_number)} ${esc(m.message)} ${statusBadge(m.status)} ${fmt(m.created_at)} `).join(''); } } catch (err) { toast('Failed to load dashboard: ' + err.message, 'error'); } } function refreshDashboard() { loadDashboard(); } // ── Received ─────────────────────────────────────────────────────────────── async function loadReceived(offset = 0) { receivedOffset = offset; const tbody = document.getElementById('received-list'); tbody.innerHTML = '
'; try { const msgs = await api('GET', `/sms/messages/received?limit=${PAGE_SIZE}&offset=${offset}`); if (!msgs.length) { tbody.innerHTML = '
📭
No received messages
'; } else { tbody.innerHTML = msgs.map(m => ` ${esc(m.phone_number)} ${esc(m.message)} ${fmt(m.created_at)} `).join(''); } renderPagination('received-pagination', offset, msgs.length, loadReceived); } catch (err) { toast('Failed to load received messages: ' + err.message, 'error'); } } // ── Sent ─────────────────────────────────────────────────────────────────── async function loadSent(offset = 0) { sentOffset = offset; const tbody = document.getElementById('sent-list'); tbody.innerHTML = '
'; try { const msgs = await api('GET', `/sms/messages/sent?limit=${PAGE_SIZE}&offset=${offset}`); if (!msgs.length) { tbody.innerHTML = '
📤
No sent messages
'; } else { tbody.innerHTML = msgs.map(m => ` ${esc(m.phone_number)} ${esc(m.message)} ${statusBadge(m.status)} ${fmt(m.created_at)} `).join(''); } renderPagination('sent-pagination', offset, msgs.length, loadSent); } catch (err) { toast('Failed to load sent messages: ' + err.message, 'error'); } } function renderPagination(id, offset, count, loader) { const el = document.getElementById(id); if (!el) return; const hasPrev = offset > 0; const hasNext = count === PAGE_SIZE; el.innerHTML = ` ${hasPrev ? `` : ''} Page ${Math.floor(offset / PAGE_SIZE) + 1} ${hasNext ? `` : ''} `; } function _paginate(paginationId, offset) { if (paginationId === 'received-pagination') loadReceived(offset); else if (paginationId === 'sent-pagination') loadSent(offset); } // ── Send SMS ─────────────────────────────────────────────────────────────── async function sendSMS(e) { e.preventDefault(); const number = document.getElementById('send-number').value.trim(); const message = document.getElementById('send-message').value.trim(); hideAlert('send-alert'); if (!number || !message) { showAlert('send-alert', 'Phone number and message are required'); return; } const btn = document.getElementById('send-btn'); btn.disabled = true; btn.textContent = 'Sending…'; try { await api('POST', '/sms/send', { number, message }); toast(`Message sent to ${number}`, 'success'); document.getElementById('send-number').value = ''; document.getElementById('send-message').value = ''; document.getElementById('char-count').textContent = '0'; } catch (err) { showAlert('send-alert', 'Failed to send: ' + err.message); } finally { btn.disabled = false; btn.textContent = '✉️ Send Message'; } } async function triggerPoll() { try { await api('POST', '/sms/poll'); toast('Device polled for new messages', 'success'); if (currentPage === 'received') loadReceived(receivedOffset); } catch (err) { toast('Poll failed: ' + err.message, 'error'); } } // ── Webhooks ─────────────────────────────────────────────────────────────── async function loadWebhooks() { const tbody = document.getElementById('webhook-list'); try { const hooks = await api('GET', '/webhooks'); if (!hooks.length) { tbody.innerHTML = '
🔗
No webhooks configured
'; } else { tbody.innerHTML = hooks.map(h => { const hookUrl = `${window.location.origin}/api/hooks/${h.token}`; return ` ${esc(h.name)} ${esc(h.phone_number)} ${esc(hookUrl)} `; }).join(''); } } catch (err) { toast('Failed to load webhooks: ' + err.message, 'error'); } } function copyWebhookUrl(url) { navigator.clipboard?.writeText(url).then(() => toast('Webhook URL copied', 'success')); } function openWebhookModal(id) { editingWebhookId = id || null; document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook'; ['wh-name', 'wh-phone', 'wh-template'].forEach(f => document.getElementById(f).value = ''); if (id) { api('GET', '/webhooks').then(hooks => { const h = hooks.find(x => x.id === id); if (!h) return; document.getElementById('wh-name').value = h.name || ''; document.getElementById('wh-phone').value = h.phone_number || ''; document.getElementById('wh-template').value = h.message_template || '{{msg}}'; }); } openModal('webhook-modal'); } async function saveWebhook() { const name = document.getElementById('wh-name').value.trim(); const phone_number = document.getElementById('wh-phone').value.trim(); const message_template = document.getElementById('wh-template').value.trim() || '{{msg}}'; if (!name || !phone_number) { toast('Name and phone number are required', 'error'); return; } const payload = { name, phone_number, message_template }; try { if (editingWebhookId) { await api('PUT', `/webhooks/${editingWebhookId}`, payload); toast('Webhook updated', 'success'); } else { await api('POST', '/webhooks', payload); toast('Webhook created', 'success'); } closeModal('webhook-modal'); loadWebhooks(); } catch (err) { toast('Save failed: ' + err.message, 'error'); } } async function toggleWebhook(id, active) { try { await api('PUT', `/webhooks/${id}`, { active: active ? 1 : 0 }); } catch (err) { toast('Update failed: ' + err.message, 'error'); } } async function deleteWebhook(id) { if (!confirm('Delete this webhook?')) return; try { await api('DELETE', `/webhooks/${id}`); toast('Webhook deleted', 'success'); loadWebhooks(); } catch (err) { toast('Delete failed: ' + err.message, 'error'); } } // ── API Tokens ───────────────────────────────────────────────────────────── async function loadTokens() { const tbody = document.getElementById('token-list'); try { const tokens = await api('GET', '/tokens'); if (!tokens.length) { tbody.innerHTML = '
🔑
No API tokens created
'; } else { tbody.innerHTML = tokens.map(t => ` ${esc(t.name)} •••••••••••••••• ${fmt(t.created_at)} `).join(''); } } catch (err) { toast('Failed to load tokens: ' + err.message, 'error'); } } function openTokenModal() { document.getElementById('token-form').style.display = 'block'; document.getElementById('token-created').style.display = 'none'; document.getElementById('token-create-btn').style.display = 'inline-flex'; document.getElementById('tk-name').value = ''; openModal('token-modal'); } async function createToken() { const name = document.getElementById('tk-name').value.trim(); if (!name) { toast('Token name is required', 'error'); return; } try { const tok = await api('POST', '/tokens', { name }); document.getElementById('new-token-value').textContent = tok.token; document.getElementById('token-form').style.display = 'none'; document.getElementById('token-created').style.display = 'block'; document.getElementById('token-create-btn').style.display = 'none'; loadTokens(); } catch (err) { toast('Failed to create token: ' + err.message, 'error'); } } function copyToken() { const val = document.getElementById('new-token-value').textContent; navigator.clipboard?.writeText(val).then(() => toast('Copied to clipboard', 'success')); } async function toggleApiToken(id, active) { try { await api('PATCH', `/tokens/${id}`, { active }); } catch (err) { toast('Update failed: ' + err.message, 'error'); } } async function deleteToken(id) { if (!confirm('Delete this token? Any apps using it will lose access.')) return; try { await api('DELETE', `/tokens/${id}`); toast('Token deleted', 'success'); loadTokens(); } catch (err) { toast('Delete failed: ' + err.message, 'error'); } } // ── Settings ─────────────────────────────────────────────────────────────── async function loadSettings() { try { const [cfg, logRes] = await Promise.all([ api('GET', '/config'), api('GET', '/log-level'), ]); document.getElementById('cfg-ip').value = cfg.device_ip || ''; document.getElementById('cfg-username').value = cfg.device_username || ''; document.getElementById('cfg-password').value = cfg.device_password || ''; document.getElementById('cfg-modem').value = cfg.device_modem || ''; document.getElementById('cfg-poll').value = cfg.poll_interval || '30'; document.getElementById('cfg-log-level').value = logRes.level || 'INFO'; } catch (err) { toast('Failed to load settings: ' + err.message, 'error'); } } async function saveLogLevel() { hideAlert('log-alert'); const level = document.getElementById('cfg-log-level').value; try { const result = await api('PUT', '/log-level', { level }); showAlert('log-alert', `Log level set to ${result.level}`, 'success'); } catch (err) { showAlert('log-alert', 'Failed: ' + err.message); } } async function saveConfig() { hideAlert('settings-alert'); const body = { device_ip: document.getElementById('cfg-ip').value.trim(), device_username: document.getElementById('cfg-username').value.trim(), device_password: document.getElementById('cfg-password').value, device_modem: document.getElementById('cfg-modem').value.trim(), poll_interval: document.getElementById('cfg-poll').value, }; try { await api('PUT', '/config', body); showAlert('settings-alert', 'Settings saved successfully', 'success'); toast('Settings saved', 'success'); } catch (err) { showAlert('settings-alert', 'Save failed: ' + err.message); } } async function testConnection() { hideAlert('settings-alert'); showAlert('settings-alert', 'Testing connection…', 'info'); try { const result = await api('POST', '/config/test'); if (result.success) { showAlert('settings-alert', '✅ Connection successful!', 'success'); } else { showAlert('settings-alert', '❌ Connection failed: ' + result.error); } } catch (err) { showAlert('settings-alert', 'Test failed: ' + err.message); } } async function changePassword() { hideAlert('pw-alert'); const currentPassword = document.getElementById('pw-current').value; const newPassword = document.getElementById('pw-new').value; const confirm = document.getElementById('pw-confirm').value; if (newPassword !== confirm) { showAlert('pw-alert', 'Passwords do not match'); return; } if (newPassword.length < 6) { showAlert('pw-alert', 'Password must be at least 6 characters'); return; } try { await api('PUT', '/auth/password', { currentPassword, newPassword }); showAlert('pw-alert', 'Password updated successfully', 'success'); document.getElementById('pw-current').value = ''; document.getElementById('pw-new').value = ''; document.getElementById('pw-confirm').value = ''; } catch (err) { showAlert('pw-alert', err.message); } } // ── Modal helpers ────────────────────────────────────────────────────────── function openModal(id) { document.getElementById(id).classList.add('open'); } function closeModal(id) { document.getElementById(id).classList.remove('open'); } // ── Init ─────────────────────────────────────────────────────────────────── function init() { // Nav clicks document.querySelectorAll('.nav-item').forEach(el => { el.addEventListener('click', () => navigate(el.dataset.page)); }); // Login form document.getElementById('login-form').addEventListener('submit', login); // Logout document.getElementById('logout-btn').addEventListener('click', logout); // Send form document.getElementById('send-form').addEventListener('submit', sendSMS); document.getElementById('send-message').addEventListener('input', e => { document.getElementById('char-count').textContent = e.target.value.length; }); // Close modal on backdrop click document.querySelectorAll('.modal-backdrop').forEach(m => { m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); }); }); checkAuth(); } // ── Public API ───────────────────────────────────────────────────────────── return { init, refreshDashboard, loadDashboard, triggerPoll, openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook, copyWebhookUrl, openTokenModal, createToken, copyToken, toggleApiToken, deleteToken, saveConfig, testConnection, saveLogLevel, changePassword, openModal, closeModal, _paginate, }; })(); document.addEventListener('DOMContentLoaded', App.init);