517 lines
27 KiB
JavaScript
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<TG>#</span> talk · <span class="mono">910#</span> leave · '
|
|
+ '<span class="mono">92<TG>#</span> QSY (<span class="mono">929#</span>→TG9 if someone interferes) · '
|
|
+ '<span class="mono">93#</span> follow QSY · <span class="mono">94<TG>#</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(); });
|
|
}
|