// Reflector Dashboard — web front-end for SvxReflector // Copyright (C) 2026 SA7AUX (Björn) // SPDX-License-Identifier: AGPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) any // later version. See the LICENSE file for details. 'use strict'; var $ = function(id){ return document.getElementById(id); }; function setMsg(el, txt, err){ if (!el) return; if (!txt){ el.style.display='none'; return; } el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80'; } function esc(s){ var d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; } function fmtDate(epoch){ if (!epoch) return ''; return new Date(epoch*1000).toISOString().slice(0,10); } async function napi(body){ var r = await fetch('/auth/node_auth_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) }); return r.json().catch(function(){ return null; }); } async function naAdminsApi(body){ var r = await fetch('/auth/node_admins_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) }); return r.json().catch(function(){ return null; }); } function wireCopy(btn, getVal){ btn.addEventListener('click', function(){ navigator.clipboard.writeText(getVal()).then(function(){ var t = btn.textContent; btn.textContent = 'Copied!'; setTimeout(function(){ btn.textContent = t; }, 1200); }); }); } function showPassword(node, pw){ $('naPwTitle').textContent = 'Password · ' + node; $('naPwValue').textContent = pw; bootstrap.Modal.getOrCreateInstance($('naPwModal')).show(); } // ── node list ────────────────────────────────────────────────────────── var allNodes = []; async function loadNodes(){ var j = await napi({action:'list'}); if (!j || !j.ok){ $('naTable').querySelector('tbody').innerHTML = ''+esc((j&&j.error)||'Failed to load')+''; return; } allNodes = j.nodes || []; renderNodes(''); } function renderNodes(filter){ var tbody = $('naTable').querySelector('tbody'); var f = (filter||'').toLowerCase(); var list = f ? allNodes.filter(function(n){ return n.node.toLowerCase().includes(f) || (n.email||'').toLowerCase().includes(f) || (n.admins||[]).some(function(a){ return a.toLowerCase().includes(f); }); }) : allNodes; $('naStatus').textContent = list.length + ' of ' + allNodes.length + ' nodes'; if (!list.length){ tbody.innerHTML = 'No nodes.'; return; } tbody.innerHTML = list.map(function(n){ var auth = []; if (n.cert) auth.push('cert'); if (n.password) auth.push('password'); var expired = n.validTo && (n.validTo*1000 < Date.now()); return ''+esc(n.node)+'' + ''+auth.join(' ')+'' + ''+esc((n.admins||[]).join(', '))+'' + ''+esc(n.email||'')+'' + ''+(n.cert ? esc(fmtDate(n.validTo))+(expired?' EXPIRED':'') : '')+'' + '' + '' + ' ' + (n.password ? ' ' : '') + ''; }).join(''); tbody.querySelectorAll('.na-edit').forEach(function(b){ b.addEventListener('click', function(){ showEdit(b.dataset.cs); }); }); tbody.querySelectorAll('.na-adm').forEach(function(b){ b.addEventListener('click', function(){ toggleAdminsRow(b); }); }); tbody.querySelectorAll('.na-del').forEach(function(b){ b.addEventListener('click', function(){ delNode(b.dataset.cs); }); }); } // base62, 20 chars — same shape as the server's gen_password(). function genPassword(){ var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var a = new Uint32Array(20); (window.crypto||window.msCrypto).getRandomValues(a); var s = ''; for (var i=0;i<20;i++) s += chars[a[i] % 62]; return s; } // Local admins list for the Add container (before the node exists). var addAdmins = []; function renderAddAdmins(){ var wrap = $('naAddAdmWrap'); var h = '
' + ''; if (!addAdmins.length) h += ''; addAdmins.forEach(function(v, i){ h += '' + ''; }); wrap.innerHTML = h + '
CallsignAction
No extra admins — the node’s own callsign always counts.
'; wrap.querySelectorAll('.aa-cs').forEach(function(inp){ inp.addEventListener('input', function(){ addAdmins[+inp.dataset.i] = inp.value; }); }); wrap.querySelectorAll('.aa-del').forEach(function(b){ b.addEventListener('click', function(){ addAdmins.splice(+b.dataset.i, 1); renderAddAdmins(); }); }); } function showAddPanel(){ $('naNewNode').value = ''; $('naNewPw').value = genPassword(); addAdmins = []; renderAddAdmins(); setMsg($('naAddMsg'), null); $('naListPanel').style.display = 'none'; $('naEditPanel').style.display = 'none'; $('naAddPanel').style.display = ''; $('naNewNode').focus(); } function addBackToList(){ $('naAddPanel').style.display = 'none'; $('naListPanel').style.display = ''; loadNodes(); } async function addNode(){ var node = ($('naNewNode').value || '').trim().toUpperCase(); var pw = ($('naNewPw').value || '').trim(); if (!node){ setMsg($('naAddMsg'), 'Enter a login (node callsign)', true); return; } if (!pw){ setMsg($('naAddMsg'), 'Enter or generate a password', true); return; } var seen = {}, admins = []; addAdmins.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; admins.push(c); } }); if (!confirm('Create node '+node+'?\nThe reflector will be restarted.')) return; setMsg($('naAddMsg'), 'Creating…'); var j = await napi({action:'add', node:node, password:pw, admins:admins}); if (!j || !j.ok){ setMsg($('naAddMsg'), (j&&(j.detail||j.error))||'Failed', true); return; } showPassword(j.node, j.password); addBackToList(); } async function delNode(cs){ if (!confirm('Delete login/password node '+cs+'?\nIt will no longer be able to connect. The reflector will be restarted.')) return; setMsg($('naMsg'), null); var j = await napi({action:'del', node:cs}); if (j && j.ok){ setMsg($('naMsg'), 'Removed '+cs, false); loadNodes(); } else setMsg($('naMsg'), (j&&(j.detail||j.error))||'Failed', true); } // ── edit view ────────────────────────────────────────────────────────── var currentCs = null; async function showEdit(cs){ currentCs = cs; var n = null; for (var i = 0; i < allNodes.length; i++) if (allNodes[i].node === cs) n = allNodes[i]; if (!n) return; $('naEditCs').textContent = cs; var badges = []; if (n.cert) badges.push('cert'); if (n.password) badges.push('password'); $('naEditType').innerHTML = badges.join(' '); // Linked portal account — full user info, like the dmr account edit $('naEditAcct').textContent = 'Loading…'; napi({action:'info', node:cs}).then(function(ij){ var box = $('naEditAcct'); if (!ij || !ij.ok){ box.innerHTML = ''+esc((ij&&ij.error)||'Failed')+''; return; } var a = ij.account; if (!a){ box.innerHTML = 'No portal account is linked to this node (no callsign or cert-email match).'; return; } var rows = [ ['Callsign', ''+esc(a.callsign)+''], ['Name', esc(a.name)], ['Email', ''+esc(a.email)+''+(a.email_verified ? ' verified' : ' unverified')], ['Admin level', String(a.admin_level)], ['Status', a.banned ? 'banned' : 'active'], ['Last login', a.last_login_at ? new Date(a.last_login_at*1000).toISOString().replace('T',' ').slice(0,16) : ''], ['Location', esc([a.city, a.country].filter(Boolean).join(', '))], ['Linked via', ij.linked_via === 'cert_email' ? 'certificate email' : 'callsign'] ]; box.innerHTML = '' + rows.filter(function(r){ return r[1] !== ''; }).map(function(r){ return ''; }).join('') + '
'+r[0]+''+r[1]+'
' + '
edit account
'; }); // Password (login/password nodes) setMsg($('naPwMsg'), null); $('naEditPwWrap').style.display = n.password ? '' : 'none'; if (n.password){ $('naEditPw').value = ''; var pj = await napi({action:'getpw', node:cs}); if (pj && pj.ok) $('naEditPw').value = pj.password; else setMsg($('naPwMsg'), (pj&&(pj.detail||pj.error))||'Could not read password', true); } // Certificate info (cert nodes) $('naEditCertWrap').style.display = n.cert ? '' : 'none'; if (n.cert){ var expired = n.validTo && (n.validTo*1000 < Date.now()); $('naEditCert').innerHTML = (n.email ? '
Email: '+esc(n.email)+'
' : '') + '
Valid to: '+esc(fmtDate(n.validTo))+(expired?' (EXPIRED)':'')+'' + ' · manage certificate
'; } buildAdminEditor($('naEditAdmins'), cs, null); $('naListPanel').style.display = 'none'; $('naEditPanel').style.display = ''; } // dmr peer "Edit admins" editor: Add on top, a row per admin with an editable // callsign field + Delete, Save/Back at the bottom. Each node keeps its own // admin list (node_admins:), so admins differ node by node. On Save // we diff the edited list against the server and issue add/remove calls. async function buildAdminEditor(box, cs, onBack){ box.innerHTML = 'Loading…'; var j = await naAdminsApi({action:'list', node:cs}); if (!j || !j.ok){ box.innerHTML = ''+esc((j&&j.error)||'Failed')+''; return; } var server = (j.admins || []).map(function(c){ return String(c).toUpperCase(); }); var working = server.slice(); box.innerHTML = '
' + '
' + '
' + (onBack ? '' : '') + '
' + ''; var wrap = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg'); function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; } function renderTable(){ var html = '
' + ''; if (!working.length) html += ''; working.forEach(function(v, i){ html += '' + ''; }); wrap.innerHTML = html + '
CallsignAction
No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)
'; wrap.querySelectorAll('.ae-cs').forEach(function(inp){ inp.addEventListener('input', function(){ working[+inp.dataset.i] = inp.value; }); }); wrap.querySelectorAll('.ae-del').forEach(function(b){ b.addEventListener('click', function(){ working.splice(+b.dataset.i, 1); renderTable(); }); }); } renderTable(); box.querySelector('.ae-add').addEventListener('click', function(){ working.push(''); renderTable(); }); box.querySelector('.ae-save').addEventListener('click', async function(){ var seen = {}, norm = []; working.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; norm.push(c); } }); var toRemove = server.filter(function(c){ return norm.indexOf(c) < 0; }); var toAdd = norm.filter(function(c){ return server.indexOf(c) < 0; }); setErr('Saving…', true); for (var i = 0; i < toRemove.length; i++){ var r = await naAdminsApi({action:'remove', node:cs, admin:toRemove[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to remove '+toRemove[i]), false); return; } } for (var i = 0; i < toAdd.length; i++){ var r = await naAdminsApi({action:'add', node:cs, admin:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } } server = norm.slice(); setErr('✓ Saved', true); }); if (onBack) box.querySelector('.ae-back').addEventListener('click', onBack); } // Inline admins editor under the node's row (Admins button in the list) function toggleAdminsRow(btn){ var cs = btn.dataset.cs; var tr = btn.closest('tr'); var next = tr.nextElementSibling; if (next && next.classList.contains('na-adm-row')){ next.remove(); return; } var open = tr.parentNode.querySelector('.na-adm-row'); if (open) open.remove(); var row = document.createElement('tr'); row.className = 'na-adm-row'; row.innerHTML = '' + '
Admins for '+esc(cs)+'
' + '
'; tr.after(row); buildAdminEditor(row.querySelector('.na-adm-box'), cs, function(){ row.remove(); }); } function backToList(){ $('naEditPanel').style.display = 'none'; $('naListPanel').style.display = ''; loadNodes(); } async function savePw(pw){ if (!currentCs) return; if (!confirm('Save password for '+currentCs+'?\nThe reflector will be restarted (nodes reconnect).')) return; setMsg($('naPwMsg'), 'Saving…'); var j = await napi({action:'resetpw', node:currentCs, password:pw}); if (j && j.ok){ $('naEditPw').value = j.password; setMsg($('naPwMsg'), 'Saved'); } else setMsg($('naPwMsg'), (j&&(j.detail||j.error))||'Failed', true); } document.addEventListener('DOMContentLoaded', function(){ fetch('/auth/me.php', {credentials:'same-origin'}) .then(function(r){ return r.json(); }) .then(function(j){ if (j && j.csrf_token) window._csrfToken = j.csrf_token; loadNodes(); }) .catch(function(){ loadNodes(); }); $('naBtnShowAdd').addEventListener('click', showAddPanel); $('naBtnAddBack').addEventListener('click', addBackToList); $('naBtnAdd').addEventListener('click', addNode); $('naNewNode').addEventListener('keydown', function(e){ if (e.key === 'Enter') addNode(); }); $('naNewPwGen').addEventListener('click', function(){ $('naNewPw').value = genPassword(); }); wireCopy($('naNewPwCopy'), function(){ return $('naNewPw').value; }); $('naAddAdmBtn').addEventListener('click', function(){ addAdmins.push(''); renderAddAdmins(); }); $('naBtnBack').addEventListener('click', backToList); $('naBtnPwSave').addEventListener('click', function(){ var pw = ($('naEditPw').value||'').trim(); if (!pw){ setMsg($('naPwMsg'), 'Empty password', true); return; } savePw(pw); }); $('naBtnPwGen').addEventListener('click', function(){ savePw(''); }); wireCopy($('naBtnPwCopy'), function(){ return $('naEditPw').value; }); wireCopy($('naPwCopy'), function(){ return $('naPwValue').textContent; }); });