332 lines
17 KiB
JavaScript
332 lines
17 KiB
JavaScript
// 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 =
|
||
'<tr><td colspan="6" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>';
|
||
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 = '<tr><td colspan="6" class="text-secondary">No nodes.</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = list.map(function(n){
|
||
var auth = [];
|
||
if (n.cert) auth.push('<span class="node-badge">cert</span>');
|
||
if (n.password) auth.push('<span class="node-badge">password</span>');
|
||
var expired = n.validTo && (n.validTo*1000 < Date.now());
|
||
return '<tr><td class="mono"><strong>'+esc(n.node)+'</strong></td>'
|
||
+ '<td>'+auth.join(' ')+'</td>'
|
||
+ '<td class="mono small">'+esc((n.admins||[]).join(', '))+'</td>'
|
||
+ '<td class="mono small">'+esc(n.email||'')+'</td>'
|
||
+ '<td class="small"'+(expired?' style="color:#ff8080;"':'')+'>'+(n.cert ? esc(fmtDate(n.validTo))+(expired?' EXPIRED':'') : '')+'</td>'
|
||
+ '<td style="white-space:nowrap;">'
|
||
+ '<button class="btn btn-outline-secondary btn-sm na-edit" data-cs="'+esc(n.node)+'" title="Edit this node">Edit</button>'
|
||
+ ' <button class="btn btn-outline-secondary btn-sm na-adm" data-cs="'+esc(n.node)+'" title="Manage node admins">Admins</button>'
|
||
+ (n.password ? ' <button class="btn btn-outline-danger btn-sm na-del" data-cs="'+esc(n.node)+'" title="Delete this node">Delete</button>' : '')
|
||
+ '</td></tr>';
|
||
}).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 = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;">'
|
||
+ '<thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
|
||
if (!addAdmins.length) h += '<tr><td colspan="2" class="small text-secondary">No extra admins — the node’s own callsign always counts.</td></tr>';
|
||
addAdmins.forEach(function(v, i){
|
||
h += '<tr><td><input type="text" class="form-control form-control-sm mono aa-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SA7XYZ" style="text-transform:uppercase;"></td>'
|
||
+ '<td><button type="button" class="btn btn-sm btn-outline-danger aa-del" data-i="'+i+'">Delete</button></td></tr>';
|
||
});
|
||
wrap.innerHTML = h + '</tbody></table></div>';
|
||
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('<span class="node-badge">cert</span>');
|
||
if (n.password) badges.push('<span class="node-badge">password</span>');
|
||
$('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 = '<span style="color:#ff8080;">'+esc((ij&&ij.error)||'Failed')+'</span>'; return; }
|
||
var a = ij.account;
|
||
if (!a){ box.innerHTML = '<span class="text-secondary">No portal account is linked to this node (no callsign or cert-email match).</span>'; return; }
|
||
var rows = [
|
||
['Callsign', '<span class="mono">'+esc(a.callsign)+'</span>'],
|
||
['Name', esc(a.name)],
|
||
['Email', '<span class="mono">'+esc(a.email)+'</span>'+(a.email_verified ? ' <span class="node-badge">verified</span>' : ' <span class="node-badge" style="border-color:#ff8080;color:#ff8080;">unverified</span>')],
|
||
['Admin level', String(a.admin_level)],
|
||
['Status', a.banned ? '<span style="color:#ff8080;">banned</span>' : '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 = '<table class="small" style="border-collapse:collapse;">'
|
||
+ rows.filter(function(r){ return r[1] !== ''; }).map(function(r){
|
||
return '<tr><td class="text-secondary" style="padding:1px 14px 1px 0;">'+r[0]+'</td><td>'+r[1]+'</td></tr>';
|
||
}).join('')
|
||
+ '</table>'
|
||
+ '<div class="mt-1"><a href="/auth/admin.php">edit account</a></div>';
|
||
});
|
||
|
||
// 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 ? '<div>Email: <span class="mono">'+esc(n.email)+'</span></div>' : '')
|
||
+ '<div>Valid to: <span'+(expired?' style="color:#ff8080;"':'')+'>'+esc(fmtDate(n.validTo))+(expired?' (EXPIRED)':'')+'</span>'
|
||
+ ' · <a href="/auth/admin_certs.php">manage certificate</a></div>';
|
||
}
|
||
|
||
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:<callsign>), 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 = '<span class="small text-secondary">Loading…</span>';
|
||
var j = await naAdminsApi({action:'list', node:cs});
|
||
if (!j || !j.ok){ box.innerHTML = '<span style="color:#ff8080;">'+esc((j&&j.error)||'Failed')+'</span>'; return; }
|
||
var server = (j.admins || []).map(function(c){ return String(c).toUpperCase(); });
|
||
var working = server.slice();
|
||
box.innerHTML =
|
||
'<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success ae-add">Add</button></div>'
|
||
+ '<div class="ae-wrap mb-2"></div>'
|
||
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ae-save">Save</button>'
|
||
+ (onBack ? '<button type="button" class="btn btn-sm btn-outline-secondary ms-auto ae-back">Back</button>' : '')
|
||
+ '</div>'
|
||
+ '<div class="ae-msg small mt-2" style="display:none;"></div>';
|
||
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 = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;">'
|
||
+ '<thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
|
||
if (!working.length) html += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)</td></tr>';
|
||
working.forEach(function(v, i){
|
||
html += '<tr><td><input type="text" class="form-control form-control-sm mono ae-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SA7AUX" style="text-transform:uppercase;"></td>'
|
||
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ae-del" data-i="'+i+'">Delete</button></td></tr>';
|
||
});
|
||
wrap.innerHTML = html + '</tbody></table></div>';
|
||
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 = '<td colspan="6" style="background:rgba(0,255,255,.04);">'
|
||
+ '<div class="small text-secondary mb-2">Admins for <span class="mono">'+esc(cs)+'</span></div>'
|
||
+ '<div class="na-adm-box"></div></td>';
|
||
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; });
|
||
});
|