// 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 '
';
}).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 = '
'
+ '
Callsign
Action
';
if (!addAdmins.length) h += '
No extra admins — the node’s own callsign always counts.
';
addAdmins.forEach(function(v, i){
h += '
'
+ '
';
});
wrap.innerHTML = h + '
';
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 = '
';
});
// 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 = '
'
+ '
Callsign
Action
';
if (!working.length) html += '
No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)
';
working.forEach(function(v, i){
html += '
'
+ '
';
});
wrap.innerHTML = html + '
';
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 = '