sms-gateway/public/js/app.js
2026-03-04 19:54:03 +01:00

581 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 = `<span>${icons[type] || ''}</span><span>${msg}</span>`;
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 `<span class="badge ${cls}">${label}</span>`;
}
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 = '<tr><td colspan="5"><div class="empty-state"><div class="icon">📭</div>No messages yet</div></td></tr>';
} else {
tbody.innerHTML = msgs.map(m => `
<tr>
<td><span class="dir-indicator" title="${m.direction}">${m.direction === 'received' ? '📥' : '📤'}</span></td>
<td><span class="phone">${esc(m.phone_number)}</span></td>
<td><span class="msg-text" title="${esc(m.message)}">${esc(m.message)}</span></td>
<td>${statusBadge(m.status)}</td>
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
</tr>`).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 = '<tr><td colspan="3"><div class="empty-state"><div class="spinner"></div></div></td></tr>';
try {
const msgs = await api('GET', `/sms/messages/received?limit=${PAGE_SIZE}&offset=${offset}`);
if (!msgs.length) {
tbody.innerHTML = '<tr><td colspan="3"><div class="empty-state"><div class="icon">📭</div>No received messages</div></td></tr>';
} else {
tbody.innerHTML = msgs.map(m => `
<tr>
<td><span class="phone">${esc(m.phone_number)}</span></td>
<td>${esc(m.message)}</td>
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
</tr>`).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 = '<tr><td colspan="4"><div class="empty-state"><div class="spinner"></div></div></td></tr>';
try {
const msgs = await api('GET', `/sms/messages/sent?limit=${PAGE_SIZE}&offset=${offset}`);
if (!msgs.length) {
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state"><div class="icon">📤</div>No sent messages</div></td></tr>';
} else {
tbody.innerHTML = msgs.map(m => `
<tr>
<td><span class="phone">${esc(m.phone_number)}</span></td>
<td>${esc(m.message)}</td>
<td>${statusBadge(m.status)}</td>
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
</tr>`).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 ? `<button class="btn btn-sm btn-outline" onclick="App._paginate('${id}',${offset - PAGE_SIZE})">← Prev</button>` : ''}
<span style="color:var(--muted);font-size:12px;">Page ${Math.floor(offset / PAGE_SIZE) + 1}</span>
${hasNext ? `<button class="btn btn-sm btn-outline" onclick="App._paginate('${id}',${offset + PAGE_SIZE})">Next →</button>` : ''}
`;
}
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 = '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔗</div>No webhooks configured</div></td></tr>';
} else {
tbody.innerHTML = hooks.map(h => {
const hookUrl = `${window.location.origin}/api/hooks/${h.token}`;
return `
<tr>
<td><strong>${esc(h.name)}</strong></td>
<td><span class="phone">${esc(h.phone_number)}</span></td>
<td style="font-size:12px;">
<span style="font-family:monospace;word-break:break-all;">${esc(hookUrl)}</span>
<button class="btn btn-sm btn-outline btn-icon" style="margin-left:6px;"
onclick="App.copyWebhookUrl('${esc(hookUrl)}')" title="Copy URL">📋</button>
</td>
<td>
<label class="toggle">
<input type="checkbox" ${h.active ? 'checked' : ''} onchange="App.toggleWebhook(${h.id}, this.checked)" />
<span class="toggle-slider"></span>
</label>
</td>
<td style="display:flex;gap:6px;">
<button class="btn btn-sm btn-outline btn-icon" onclick="App.openWebhookModal(${h.id})" title="Edit">✏️</button>
<button class="btn btn-sm btn-danger btn-icon" onclick="App.deleteWebhook(${h.id})" title="Delete">🗑</button>
</td>
</tr>`;
}).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 = '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔑</div>No API tokens created</div></td></tr>';
} else {
tbody.innerHTML = tokens.map(t => `
<tr>
<td><strong>${esc(t.name)}</strong></td>
<td style="color:var(--muted);font-size:12px;font-family:monospace;">••••••••••••••••</td>
<td>
<label class="toggle">
<input type="checkbox" ${t.active ? 'checked' : ''} onchange="App.toggleApiToken(${t.id}, this.checked)" />
<span class="toggle-slider"></span>
</label>
</td>
<td style="color:var(--muted);font-size:12px;">${fmt(t.created_at)}</td>
<td>
<button class="btn btn-sm btn-danger btn-icon" onclick="App.deleteToken(${t.id})" title="Delete">🗑</button>
</td>
</tr>`).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);