Files
reflector-dashboard/auth/profile_nodes.js
T

517 lines
27 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';
function switchProfileTab(tab) {
document.querySelectorAll('.profile-tab').forEach(function(b) {
var active = b.dataset.ptab === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.profile-tab-pane').forEach(function(p) {
p.style.display = p.id === 'ptab-' + tab ? '' : 'none';
});
if (tab === 'nodes' && !nodesLoaded) loadNodes();
if (tab === 'mqtt' && !mqttLoaded) loadMqtt();
if (tab === 'nodesec' && !nodeSecLoaded) { nodeSecLoaded = true; loadNodeAuth(); }
}
var nodesLoaded = false;
var mqttLoaded = false;
var nodeSecLoaded = false;
var allNodes = {};
async function loadNodes() {
var status = document.getElementById('nodesStatus');
var list = document.getElementById('nodesList');
var selWrap = document.getElementById('nodeSelWrap');
var sel = document.getElementById('nodeSelect');
try {
var r = await fetch('/auth/my_nodes.php', {credentials:'same-origin'});
var j = await r.json();
if (!j || !j.ok) { status.textContent = j && j.error || 'Error'; return; }
nodesLoaded = true;
allNodes = j.nodes || {};
var keys = Object.keys(allNodes);
if (keys.length === 0) {
status.textContent = 'No nodes connected';
list.innerHTML = '<div class="text-secondary small">No nodes matching your callsign are currently connected to the reflector.</div>';
selWrap.style.display = 'none';
return;
}
status.textContent = keys.length + ' node' + (keys.length > 1 ? 's' : '');
// Populate instance selector
sel.innerHTML = '';
if (keys.length > 1) {
var optAll = document.createElement('option');
optAll.value = '__all__';
optAll.textContent = 'All nodes (' + keys.length + ')';
sel.appendChild(optAll);
}
for (var i = 0; i < keys.length; i++) {
var opt = document.createElement('option');
opt.value = keys[i];
opt.textContent = keys[i];
sel.appendChild(opt);
}
selWrap.style.display = (keys.length > 1) ? '' : 'none';
// Show first node or all
if (keys.length > 1) {
showNodes('__all__');
} else {
showNodes(keys[0]);
}
} catch(e) { status.textContent = 'Failed to load'; }
}
function showNodes(selected) {
var list = document.getElementById('nodesList');
list.innerHTML = '';
var keys = Object.keys(allNodes);
for (var i = 0; i < keys.length; i++) {
if (selected !== '__all__' && keys[i] !== selected) continue;
list.appendChild(renderNode(keys[i], allNodes[keys[i]]));
}
}
function renderNode(callsign, n) {
var card = document.createElement('div');
card.className = 'node-card';
var html = '<h6 class="mono"><strong>' + esc(callsign) + '</strong>';
if (n.nodeClass) html += ' <span class="node-badge">' + esc(n.nodeClass) + '</span>';
if (n.isTalker) html += ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">TX</span>';
html += '</h6>';
html += '<dl class="node-grid">';
if (n.nodeLocation) html += dt('Location') + dd(n.nodeLocation);
if (n.sysop) html += dt('Sysop') + dd(n.sysop);
if (n.sw) html += dt('Software') + dd(n.sw + ' ' + (n.swVer || ''));
if (n.projVer) html += dt('Build') + dd(n.projVer);
if (n.machineArch) html += dt('Arch') + dd(n.machineArch);
if (n.protoVer) html += dt('Protocol') + dd('v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer);
if (n.tg !== undefined) html += dt('Current TG') + dd(n.tg || 'None');
html += '</dl>';
// Node admins editor appended below (dmr-style table); sysop is the
// implicit owner, others editable by owner / node admin / site admin.
if (n.monitoredTGs && n.monitoredTGs.length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">Monitored TGs:</span><br>';
for (var i = 0; i < n.monitoredTGs.length; i++) html += '<span class="node-badge mono">' + n.monitoredTGs[i] + '</span>';
html += '</div>';
}
if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">CTCSS Tone Mapping:</span>';
html += '<table style="font-size:.8rem;margin-top:4px;border-collapse:collapse;">';
var sorted = Object.entries(n.toneToTalkgroup).sort(function(a,b){ return parseFloat(a[0]) - parseFloat(b[0]); });
for (var i = 0; i < sorted.length; i++) {
html += '<tr><td class="mono" style="padding:1px 10px 1px 0;color:var(--fg2);">' + sorted[i][0] + ' Hz</td><td class="mono" style="padding:1px 0;">TG ' + sorted[i][1] + '</td></tr>';
}
html += '</table></div>';
}
if (n.qth && n.qth.length > 0) {
for (var qi = 0; qi < n.qth.length; qi++) {
var q = n.qth[qi];
html += '<div class="mt-2" style="border-top:1px solid var(--line);padding-top:8px;">';
html += '<span class="small text-secondary">QTH: ' + esc(q.name || '') + '</span>';
html += '<dl class="node-grid mt-1">';
if (q.pos) {
if (q.pos.loc) html += dt('Locator') + dd(q.pos.loc);
if (q.pos.lat) html += dt('Lat/Long') + dd(q.pos.lat + ', ' + q.pos.long);
}
if (q.rx) {
for (var id in q.rx) {
var rx = q.rx[id];
html += dt('RX ' + id) + dd((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':''));
html += dt('Status') + dd((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':''));
}
}
if (q.tx) {
for (var id in q.tx) {
var tx = q.tx[id];
html += dt('TX ' + id) + dd((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':''));
html += dt('Transmitting') + dd(tx.transmit?'Yes':'No');
}
}
html += '</dl>';
html += '</div>';
}
}
card.innerHTML = html;
return card;
}
// ===== Remote (MQTT) tab: provision + show this node's broker credentials =====
function loadMqtt() {
mqttLoaded = true;
var pane = document.getElementById('mqttPane');
fetch('/auth/me.php', { credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(me) {
if (me && me.csrf_token) window._csrfToken = me.csrf_token;
var call = ((me && me.callsign) || '').toUpperCase();
if (!call) { pane.innerHTML = '<span class="text-secondary small">Your account has no callsign set — add one on the My Account tab first.</span>'; return; }
pane.innerHTML = '';
var wrap = document.createElement('div');
var head = document.createElement('div');
head.className = 'mb-2';
head.innerHTML = 'Node callsign: <b class="mono">' + esc(call) + '</b>';
var btn = document.createElement('button');
btn.className = 'btn btn-primary btn-sm';
btn.textContent = 'Show / set up credentials';
var creds = document.createElement('div');
creds.className = 'mqtt-creds small mono mt-2';
btn.addEventListener('click', function() {
btn.disabled = true;
mqttApi({ action: 'mqtt_node_get', callsign: call }).then(function(j) {
btn.disabled = false;
if (j && j.ok) mqttCreds(call, creds, j);
else { creds.style.display = 'block'; creds.textContent = (j && j.error) || 'error'; }
}).catch(function() { btn.disabled = false; });
});
wrap.appendChild(head); wrap.appendChild(btn); wrap.appendChild(creds);
// Change talkgroup (owner can control their own node)
var tgWrap = document.createElement('div');
tgWrap.className = 'mt-3';
tgWrap.style.borderTop = '1px solid var(--line)';
tgWrap.style.paddingTop = '10px';
tgWrap.innerHTML = '<div class="small text-secondary mb-1">Send a SvxLink DTMF command (remote control must be ON). '
+ '<span class="mono">91&lt;TG&gt;#</span> talk · <span class="mono">910#</span> leave · '
+ '<span class="mono">92&lt;TG&gt;#</span> QSY (<span class="mono">929#</span>→TG9 if someone interferes) · '
+ '<span class="mono">93#</span> follow QSY · <span class="mono">94&lt;TG&gt;#</span> monitor · <span class="mono">9*#</span> status</div>';
var row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-center';
var inp = document.createElement('input');
inp.className = 'form-control form-control-sm mono'; inp.style.maxWidth = '180px';
inp.type = 'text'; inp.placeholder = 'DTMF, e.g. 91240#';
var setb = document.createElement('button');
setb.className = 'btn btn-primary btn-sm'; setb.type = 'button'; setb.textContent = 'Send';
var st = document.createElement('span'); st.className = 'small';
setb.addEventListener('click', function() {
var tg = (inp.value || '').trim().toUpperCase();
if (!/^[0-9A-D*#]{1,16}$/.test(tg)) { st.style.color = '#ff5252'; st.textContent = 'Use 0-9, #, * and A-D'; return; }
setb.disabled = true; st.style.color = ''; st.textContent = 'Sending…';
mqttApi({ action: 'dtmf', callsign: call, dtmf: tg }).then(function(j) {
setb.disabled = false;
if (j && j.ok) { st.style.color = '#00e676'; st.textContent = 'Sent: ' + tg; }
else { st.style.color = '#ff5252'; st.textContent = (j && j.error) || 'failed'; }
}).catch(function() { setb.disabled = false; st.style.color = '#ff5252'; st.textContent = 'request failed'; });
});
row.appendChild(inp); row.appendChild(setb); row.appendChild(st);
tgWrap.appendChild(row);
wrap.appendChild(tgWrap);
pane.appendChild(wrap);
}).catch(function() { pane.innerHTML = '<span class="text-secondary small">Failed to load.</span>'; });
}
function mqttApi(body) {
return fetch('/auth/node_cmd_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); });
}
function mqttCopyBtn(label, value) {
return '<button type="button" class="btn btn-sm btn-outline-secondary ms-2 py-0" ' +
'data-copy="' + esc(value) + '" style="font-size:.72rem;">' + label + '</button>';
}
function mqttWireCopy(cont) {
cont.querySelectorAll('[data-copy]').forEach(function(b) {
b.addEventListener('click', function() {
var v = b.getAttribute('data-copy');
var done = function() { var t = b.textContent; b.textContent = 'Copied!'; setTimeout(function() { b.textContent = t; }, 1200); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).then(done).catch(function() { mqttCopyFallback(v, done); });
} else { mqttCopyFallback(v, done); }
});
});
}
function mqttCopyFallback(v, done) {
var ta = document.createElement('textarea');
ta.value = v; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
function mqttCreds(call, cont, d) {
cont.style.display = 'block';
if (d.password) {
cont.innerHTML =
'<div>Broker: <b>' + esc(d.broker_host) + ':' + d.broker_port + '</b></div>' +
'<div>Username: <b>' + esc(d.username) + '</b>' + mqttCopyBtn('Copy', d.username) + '</div>' +
'<div>Password: <b>' + esc(d.password) + '</b>' + mqttCopyBtn('Copy', d.password) + '</div>' +
'<div class="text-secondary" style="margin-top:4px;">Paste these into the node dashboard → Admin → Remote Ctrl, then Turn ON.</div>' +
'<button class="btn btn-sm btn-warning mt-1" data-regen="1">Regenerate</button>';
cont.querySelector('[data-regen]').addEventListener('click', function() { mqttProvision(call, cont); });
mqttWireCopy(cont);
} else {
cont.innerHTML = '<button class="btn btn-sm btn-primary" data-gen="1">Generate password</button>';
cont.querySelector('[data-gen]').addEventListener('click', function() { mqttProvision(call, cont); });
}
}
function mqttProvision(call, cont) {
if (!confirm('Generate a NEW MQTT password for ' + call + '? Any old one stops working.')) return;
cont.innerHTML = '<span class="text-secondary">Working…</span>';
mqttApi({ action: 'mqtt_node_provision', callsign: call }).then(function(j) {
if (j && j.ok) mqttCreds(call, cont, j);
else cont.textContent = (j && j.error) || 'provision failed';
}).catch(function() { cont.textContent = 'request failed'; });
}
function dt(l) { return '<dt>' + esc(l) + '</dt>'; }
function dd(v) { return '<dd>' + esc(String(v)) + '</dd>'; }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.profile-tab[data-ptab]').forEach(function(btn) {
btn.addEventListener('click', function() { switchProfileTab(btn.dataset.ptab); });
});
var sel = document.getElementById('nodeSelect');
if (sel) {
sel.addEventListener('change', function() { showNodes(sel.value); });
}
var rv = document.getElementById('btnResendVerify');
if (rv) {
rv.addEventListener('click', async function() {
var msg = document.getElementById('resendMsg');
rv.disabled = true;
if (msg) { msg.style.color = ''; msg.textContent = 'Sending\u2026'; }
try {
var r = await fetch('/auth/resend_verification.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' }
});
var j = await r.json();
if (j && j.ok) {
if (msg) { msg.style.color = '#00c864'; msg.textContent = j.already ? 'Already verified.' : 'Verification email sent \u2014 check your inbox.'; }
} else {
if (msg) { msg.style.color = '#ff8080'; msg.textContent = (j && j.error) || 'Failed'; }
rv.disabled = false;
}
} catch (e) {
if (msg) { msg.style.color = '#ff8080'; msg.textContent = 'Failed'; }
rv.disabled = false;
}
});
}
});
// --- Reflector authentication (password/cert nodes linked to this account) ---
function nauthApi(body) {
var post = function () {
return 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)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
};
if (window._csrfToken) return post();
return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
.then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); })
.catch(function () { return post(); });
}
function loadNodeAuth() {
var cont = document.getElementById('nodeAuthList');
if (!cont) return;
nauthApi({ action: 'list' }).then(function (j) {
if (!j || !j.ok) { cont.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed to load') + '</span>'; return; }
var list = j.nodes || [];
var pend = j.pending || [];
if (!list.length && !pend.length) { cont.innerHTML = '<span class="small text-secondary">No reflector logins or certificates are linked to your account.</span>'; return; }
cont.innerHTML = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:520px;">'
+ '<thead><tr><th>Callsign</th><th>Auth</th><th>Password</th><th>Actions</th></tr></thead><tbody></tbody></table></div>';
var tb = cont.querySelector('tbody');
list.forEach(function (n) {
var auth = [];
if (n.cert) {
var expired = n.validTo && (n.validTo * 1000 < Date.now());
auth.push('<span class="node-badge">cert' + (n.validTo ? ' · ' + new Date(n.validTo * 1000).toISOString().slice(0, 10) : '') + '</span>'
+ (expired ? ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">EXPIRED</span>' : ''));
if (typeof n.renewals !== 'undefined') auth.push('<span class="node-badge" title="Times this certificate has been issued/renewed">renewals: ' + (n.renewals | 0) + '</span>');
}
if (n.password) auth.push('<span class="node-badge">password</span>');
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(n.node) + '</strong></td>'
+ '<td>' + auth.join(' ') + '</td>'
+ '<td class="na-pw mono small">' + (n.password ? 'Loading…' : '<span class="text-secondary">—</span>') + '</td>'
+ '<td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var actCell = tr.querySelector('.na-act');
// Remove-certificate (self-service) — for a cert linked to your account
// by callsign or verified email. Removal goes through the reflector (CA RM).
if (n.cert && n.owned) {
var rmBtn = document.createElement('button');
rmBtn.type = 'button'; rmBtn.className = 'btn btn-sm btn-outline-warning py-0 me-1';
rmBtn.style.fontSize = '.75rem'; rmBtn.textContent = 'Revoke certificate';
rmBtn.addEventListener('click', function () {
if (!confirm('Remove the certificate for ' + n.node + '?\nThe node can then reconnect and submit a new request, which you can accept here.')) return;
rmBtn.disabled = true;
nauthApi({ action: 'self_revoke', node: n.node }).then(function (r) {
if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); }
else { rmBtn.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); }
});
});
actCell.appendChild(rmBtn);
}
// Admins — expand the dmr-style editor under the row (all node types).
var admBtn = document.createElement('button');
admBtn.type = 'button'; admBtn.className = 'btn btn-sm btn-outline-secondary py-0 me-1';
admBtn.style.fontSize = '.75rem'; admBtn.textContent = 'Admins';
admBtn.addEventListener('click', function () {
var nx = tr.nextElementSibling;
if (nx && nx.classList.contains('na-adm-sub')) { nx.remove(); return; }
var open = tb.querySelector('.na-adm-sub'); if (open) open.remove();
var sub = document.createElement('tr'); sub.className = 'na-adm-sub';
var td = document.createElement('td'); td.colSpan = 4; td.style.background = 'rgba(0,255,255,.04)';
// Node Security never edits admins — not even for site admins; that is
// done on the admin account-edit page. Read-only list only.
td.appendChild(nauthAdminsEditor(n.node, false));
sub.appendChild(td); tr.after(sub);
});
actCell.appendChild(admBtn);
if (n.password) {
var pwCell = tr.querySelector('.na-pw');
var showPw = function (pw) { pwCell.innerHTML = '<span>' + esc(pw) + '</span>' + mqttCopyBtn('Copy', pw); mqttWireCopy(pwCell); };
nauthApi({ action: 'getpw', node: n.node }).then(function (r) {
if (r && r.ok) showPw(r.password);
else pwCell.textContent = (r && (r.detail || r.error)) || 'failed';
}).catch(function () { pwCell.textContent = 'request failed'; });
var btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-secondary py-0';
btn.style.fontSize = '.75rem'; btn.textContent = 'Reset password';
btn.addEventListener('click', function () {
if (!confirm('Generate a NEW reflector password for ' + n.node + '?\nThe old one stops working and the reflector restarts (nodes reconnect).')) return;
btn.disabled = true; pwCell.textContent = 'Working…';
nauthApi({ action: 'resetpw', node: n.node }).then(function (r) {
btn.disabled = false;
if (r && r.ok) showPw(r.password);
else pwCell.textContent = (r && (r.detail || r.error)) || 'failed';
}).catch(function () { btn.disabled = false; pwCell.textContent = 'request failed'; });
});
actCell.appendChild(btn);
}
});
// Pending certificate requests — a node reconnecting after its cert was
// removed submits a fresh CSR that lands here. You can accept (issue) it
// yourself once an admin has issued the first certificate for the node or
// your account email (callsign or email match links it to you).
pend.forEach(function (p) {
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(p.node) + '</strong></td>'
+ '<td><span class="node-badge" style="border-color:#ffc800;color:#ffc800;">cert request</span>'
+ (typeof p.renewals !== 'undefined' ? ' <span class="node-badge">renewals: ' + (p.renewals | 0) + '</span>' : '') + '</td>'
+ '<td class="text-secondary small">—</td>'
+ '<td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var cell = tr.querySelector('.na-act');
if (p.canSelfSign) {
var acc = document.createElement('button');
acc.type = 'button'; acc.className = 'btn btn-sm btn-success py-0';
acc.style.fontSize = '.75rem'; acc.textContent = 'Accept / issue';
acc.addEventListener('click', function () {
if (!confirm('Issue the certificate for ' + p.node + '?')) return;
acc.disabled = true;
nauthApi({ action: 'self_sign', node: p.node }).then(function (r) {
if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); }
else { acc.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); }
});
});
cell.appendChild(acc);
} else {
cell.innerHTML = '<span class="small text-secondary">Waiting for an admin to issue the first certificate.</span>';
}
});
});
}
// Per-node admins editor — exactly the dmr peer "Edit admins" table: 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 the edited list is diffed against the
// server and add/remove calls are issued. Editing is site-admin only —
// regular users get a read-only list (the server enforces the same rule).
function nauthAdminsEditor(cs, editable) {
var wrap = document.createElement('div');
wrap.className = 'mt-2';
wrap.innerHTML = '<div class="small text-secondary mb-2">'
+ (editable ? 'Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.'
: 'Node admins for this node. Only site admins can change the list.')
+ '</div><div class="ae-box"></div>';
var box = wrap.querySelector('.ae-box');
box.innerHTML = '<span class="small text-secondary">Loading…</span>';
naApi({ action: 'list', node: cs }).then(function (j) {
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(); });
if (!editable) {
var ro = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;"><thead><tr><th>Callsign</th></tr></thead><tbody>';
if (!server.length) ro += '<tr><td class="small text-secondary">No extra admins. (The node\'s own callsign always counts.)</td></tr>';
server.forEach(function (c) { ro += '<tr><td class="mono">' + esc(c) + '</td></tr>'; });
box.innerHTML = ro + '</tbody></table></div>';
return;
}
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></div>'
+ '<div class="ae-msg small mt-2" style="display:none;"></div>';
var tw = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg');
var setErr = function (t, ok) { msg.style.display = t ? '' : 'none'; msg.style.color = ok ? '#00ff80' : '#ff8080'; msg.textContent = t || ''; };
var renderTable = function () {
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. (Your 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>';
});
tw.innerHTML = html + '</tbody></table></div>';
tw.querySelectorAll('.ae-cs').forEach(function (inp) { inp.addEventListener('input', function () { working[+inp.dataset.i] = inp.value; }); });
tw.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', 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);
var chain = Promise.resolve(true);
toRemove.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'remove', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to remove ' + c), false); return false; } return true; }); }); });
toAdd.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'add', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to add ' + c), false); return false; } return true; }); }); });
chain.then(function (ok) { if (ok) { server = norm.slice(); setErr('✓ Saved', true); } });
});
});
return wrap;
}
// --- Node admin management (add/remove) — owner / node admin / site admin ---
function naApi(body) {
var post = function () {
return 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)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
};
// The CSRF token is only guaranteed once me.php has been fetched; ensure it's set.
if (window._csrfToken) return post();
return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
.then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); })
.catch(function () { return post(); });
}