Files
reflector-dashboard/auth/admin_nodeauth.js
T

332 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// 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; });
});