Files

857 lines
43 KiB
JavaScript
Raw Permalink 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 LEVEL_LABELS = {1:'Normal', 2:'Admin', 3:'SuperAdmin'};
var allAccounts = [];
var currentPage = 1;
var pageSize = 25;
var currentUuid = null;
var currentAcct = null;
var editNodes = {};
var editNodesLoaded = false;
var $ = function(id) { return document.getElementById(id); };
function setMsg(el, txt, err) {
if (!el) return;
if (!txt) { el.style.display = 'none'; el.textContent = ''; return; }
el.style.display = 'block';
el.textContent = txt;
el.style.color = err ? '#ff8080' : '#00ff80';
}
function fmtTime(t) { if (!t) return '-'; return new Date(t * 1000).toISOString().slice(0,16).replace('T',' '); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
async function ensureCsrf() {
if (window._csrfToken) return;
try {
var m = await (await fetch('/auth/me.php', {credentials:'same-origin'})).json();
if (m && m.csrf_token) window._csrfToken = m.csrf_token;
if (m && typeof m.flags !== 'undefined') window._actingFlags = (m.flags | 0);
} catch (_) {}
}
async function api(body) {
await ensureCsrf();
var send = function() {
return fetch('/auth/admin_accounts.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; }); });
};
var j = await send();
// Stale/missing token → fetch a fresh one and retry once, so writes never fail silently.
if (j && j.error === 'invalid_csrf_token') {
window._csrfToken = null;
await ensureCsrf();
j = await send();
}
return j;
}
/* ======== ACCOUNT FLAGS POPUP ========
8-bit flags as on the dmr site: several can be set at once.
1=unreg 2=reg 4=normal 8=lvl1 16=lvl2 32=lvl3 */
var ACCT_FLAGS = [
{bit:1, label:'unreg'},
{bit:2, label:'reg'},
{bit:4, label:'normal'},
{bit:8, label:'lvl1'},
{bit:16, label:'lvl2'},
{bit:32, label:'lvl3'}
];
function flagsLabel(f) {
var names = [];
for (var i = 0; i < ACCT_FLAGS.length; i++) if (f & ACCT_FLAGS[i].bit) names.push(ACCT_FLAGS[i].label);
return names.length ? names.join(', ') : '-';
}
var flagsModalState = null; // {uuid, flags, pending, verified, onChange}
// enable/disable control, same as the dmr site: a one-click Enable/Disable toggle
// (green Enable when off / gold Disable when on). Behåller interfacet (.value ==
// 'true'/'false', 'change'-event, .disabled) så anroparen är oförändrad.
function makeBoolSelect(val, disabled) {
var b = document.createElement('button');
b.type = 'button';
function paint(state) {
b.className = 'btn btn-sm ' + (state ? 'btn-warning' : 'btn-success');
b.textContent = state ? 'Disable' : 'Enable';
b.value = state ? 'true' : 'false';
b.dataset.on = state ? '1' : '0';
}
paint(!!val);
b.disabled = !!disabled;
b.addEventListener('click', function() {
if (b.disabled) return;
paint(b.dataset.on !== '1');
b.dispatchEvent(new Event('change', { bubbles: true }));
});
return b;
}
function renderFlagsModal() {
var st = flagsModalState;
var box = $('flagsModalBtns');
if (!st || !box) return;
// Acting flags come from the fresh me.php fetch first (never cached/undefined),
// then the inline global. If neither is known yet, act as full admin (rank 3) so
// the level controls are never wrongly locked — the server still enforces the rules.
var af = (typeof window._actingFlags === 'number') ? window._actingFlags
: ((typeof ADMIN_FLAGS !== 'undefined') ? ADMIN_FLAGS : null);
var actingRank = (af === null) ? 3
: ((af & 32) ? 3 : ((af & 16) ? 2 : ((af & 8) ? 1 : 0)));
box.innerHTML = '';
ACCT_FLAGS.forEach(function(fl) {
var on = (st.pending & fl.bit) !== 0;
var dis = false;
if (fl.bit === 2 && !st.verified && !on) dis = true; // reg requires verified email
if (fl.bit === 4 && !(st.pending & 2) && !on) dis = true; // normal requires reg
if ((fl.bit & (8|16|32)) && !(st.pending & 4) && !on) dis = true; // admin requires normal (mirrors dmr: admin requires verified)
if (fl.bit & (8|16|32)) {
var oldRank = (st.pending & 32) ? 3 : ((st.pending & 16) ? 2 : ((st.pending & 8) ? 1 : 0));
var thisRank = (fl.bit === 32) ? 3 : ((fl.bit === 16) ? 2 : 1);
if (actingRank < 3 && (oldRank >= actingRank || thisRank >= actingRank)) dis = true;
}
var row = document.createElement('div');
row.className = 'd-flex align-items-center justify-content-between';
var lbl = document.createElement('label');
lbl.className = 'small mono mb-0';
lbl.textContent = fl.label;
var sel = makeBoolSelect(on, dis);
sel.addEventListener('change', function() {
var turnOn = sel.value === 'true';
if (fl.bit & (8|16|32)) {
// Admin level is exclusive — one of lvl1/lvl2/lvl3 at a time.
st.pending = turnOn ? ((st.pending & ~(8|16|32)) | fl.bit) : (st.pending & ~fl.bit);
} else {
st.pending = turnOn ? (st.pending | fl.bit) : (st.pending & ~fl.bit);
if (!(st.pending & 2)) st.pending &= ~4; // dropping reg also drops normal
if (!(st.pending & 4)) st.pending &= ~(8|16|32); // dropping normal also drops admin (mirrors dmr)
}
renderFlagsModal();
});
row.appendChild(lbl);
row.appendChild(sel);
box.appendChild(row);
});
}
function openFlagsModal(uuid, callsign, flags, verified, onChange) {
flagsModalState = {uuid:uuid, flags:flags, pending:flags, verified:verified, onChange:onChange};
$('flagsModalCs').textContent = callsign || '';
setMsg($('flagsModalMsg'), null);
renderFlagsModal();
if (window.bootstrap) new bootstrap.Modal($('acctFlagsModal')).show();
}
async function rapi(body) {
var r = await fetch('/auth/admin_reflector_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; });
}
/* ======== TAB SWITCHING ======== */
function switchTab(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' && !editNodesLoaded) loadEditNodes();
}
/* ======== ACCOUNT LIST ======== */
async function loadAccounts() {
var j = await api({action:'list'});
if (j && j.ok) {
allAccounts = j.accounts;
$('loadMsg').textContent = ''; // count is shown by the pagination row
renderAccounts('');
} else {
$('loadMsg').textContent = 'Error';
}
}
function renderAccounts(filter) {
var tbody = $('acctTable').querySelector('tbody');
tbody.innerHTML = '';
var f = (filter || '').toLowerCase();
var allRows = f ? allAccounts.filter(function(a) {
return (a.callsign||'').toLowerCase().includes(f) || ((a.firstname||'')+' '+(a.lastname||'')).toLowerCase().includes(f) || (a.email||'').toLowerCase().includes(f);
}) : allAccounts;
var totalPages = Math.max(1, Math.ceil(allRows.length / pageSize));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * pageSize;
var rows = allRows.slice(start, start + pageSize);
renderPagination(allRows.length, totalPages);
for (var i = 0; i < rows.length; i++) {
var a = rows[i];
// Status reflects login-eligibility: approved (normal bit 4) = can log in.
// Verified-but-unapproved is the "waiting for admin" state the bell surfaces.
var approved = ((a.flags || 0) & 4) !== 0;
var status = a.banned ? '<span style="color:#ff4040;">BANNED</span>'
: (!a.verified ? '<span style="color:#888;">Unverified</span>'
: (approved ? '<span style="color:#0f0;">Active</span>' : '<span style="color:#ff9800;">Pending approval</span>'));
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>'+esc(a.callsign||'')+'</strong></td><td>'+esc((a.firstname||'')+' '+(a.lastname||''))+'</td><td class="small">'+esc(a.email||'')+'</td><td class="acct-flags-cell" data-uuid="'+a.uuid+'">'+esc(flagsLabel(a.flags||0))+'</td><td>'+status+'</td><td class="small mono">'+fmtTime(a.last_login)+'</td><td>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm acct-level me-1" data-uuid="'+a.uuid+'">Admin</button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm acct-edit me-1" data-uuid="'+a.uuid+'">Edit</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm acct-del me-1" data-uuid="'+a.uuid+'" data-cs="'+esc(a.callsign||'')+'" title="Permanently delete this account">Delete</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm acct-ban me-1" data-uuid="'+a.uuid+'" data-cs="'+esc(a.callsign||'')+'" title="Ban this user — they will not be able to log in or use any services.">Ban</button>'
+ '<button type="button" class="btn btn-outline-success btn-sm acct-unban" data-uuid="'+a.uuid+'" title="Remove ban — restore access for this user.">Unban</button>'
+ '</td>';
tbody.appendChild(tr);
}
tbody.querySelectorAll('.acct-level').forEach(function(btn) {
btn.addEventListener('click', function() {
var acct = null;
for (var k = 0; k < allAccounts.length; k++) if (allAccounts[k].uuid === btn.dataset.uuid) { acct = allAccounts[k]; break; }
if (!acct) return;
openFlagsModal(acct.uuid, acct.callsign || '', acct.flags || 0, !!acct.verified, function(nf) {
acct.flags = nf;
var cell = tbody.querySelector('.acct-flags-cell[data-uuid="'+acct.uuid+'"]');
if (cell) cell.textContent = flagsLabel(nf);
});
});
});
tbody.querySelectorAll('.acct-ban').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Ban ' + (btn.dataset.cs || btn.dataset.uuid) + '? They will not be able to log in or use any services.')) return;
var reason = prompt('Ban reason (optional):') || '';
var j = await api({action:'ban', uuid:btn.dataset.uuid, reason:reason});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
tbody.querySelectorAll('.acct-unban').forEach(function(btn) {
btn.addEventListener('click', async function() {
var j = await api({action:'unban', uuid:btn.dataset.uuid});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
tbody.querySelectorAll('.acct-edit').forEach(function(btn) {
btn.addEventListener('click', function() { openEdit(btn.dataset.uuid); });
});
tbody.querySelectorAll('.acct-del').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Permanently delete account ' + (btn.dataset.cs || btn.dataset.uuid) + '?')) return;
var j = await api({action:'delete', uuid:btn.dataset.uuid});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
}
/* ======== EDIT VIEW ======== */
function setVerifiedStatus() {
var el = document.getElementById('acctVerifiedStatus');
if (!el || !currentAcct) return;
var v = String(currentAcct.email_verified || '0') === '1';
el.innerHTML = v ? '<span style="color:#00c864;">Verified</span>' : '<span style="color:#ffc800;">Not verified</span>';
}
// Approval badge next to the callsign — mirrors the dmr site so an admin can
// see login-eligibility at a glance. Approved = normal bit (4) set.
function setApprovalBadge() {
var el = document.getElementById('acctApprovalBadge');
if (!el || !currentAcct) return;
var banned = (parseInt(currentAcct.banned, 10) || 0) === 1;
var verified = String(currentAcct.email_verified || '0') === '1';
var approved = ((parseInt(currentAcct.flags, 10) || 0) & 4) !== 0;
el.className = 'badge ms-2';
if (banned) { el.style.background = '#ff4040'; el.style.color = '#fff'; el.textContent = 'BANNED'; }
else if (approved) { el.style.background = '#00c864'; el.style.color = '#062'; el.textContent = 'Approved — can log in'; }
else if (verified) { el.style.background = '#ff9800'; el.style.color = '#3a2500'; el.textContent = 'Pending approval — cannot log in'; }
else { el.style.background = '#888'; el.style.color = '#fff'; el.textContent = 'Not verified — email not confirmed'; }
// Approve only makes sense once email is verified (set_flags rejects reg on an
// unverified account). Show Approve when pending, Unapprove when approved.
var bA = document.getElementById('btnApprove');
var bU = document.getElementById('btnUnapprove');
if (bA) bA.style.display = (!banned && verified && !approved) ? '' : 'none';
if (bU) bU.style.display = (!banned && approved) ? '' : 'none';
}
async function openEdit(uuid) {
currentUuid = uuid;
editNodesLoaded = false;
var j = await api({action:'get', uuid:uuid});
if (!j || !j.ok) return;
currentAcct = j.account;
$('acctEditCs').textContent = currentAcct.callsign || uuid;
setApprovalBadge();
// Render fields — dmr peer-editor "stuk": grouped headers, one row per field
// with a right-aligned 200px label and a capped mono input.
var container = $('acctFields');
container.innerHTML = '';
var groups = [
{label:'Identity', fields:[{key:'callsign',label:'Callsign'},{key:'firstname',label:'First name'},{key:'lastname',label:'Last name'},{key:'shortname',label:'Short name'},{key:'email',label:'Email'}]},
{label:'Location', fields:[{key:'city',label:'City'},{key:'state',label:'State'},{key:'country',label:'Country'}]}
];
for (var g = 0; g < groups.length; g++) {
var h = document.createElement('h6');
h.className = (g === 0 ? 'mt-2' : 'mt-3') + ' mb-2';
h.style.color = 'var(--fg2)';
h.textContent = groups[g].label;
container.appendChild(h);
for (var i = 0; i < groups[g].fields.length; i++) {
var f = groups[g].fields[i];
var row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-center mb-1';
var lbl = document.createElement('label');
lbl.className = 'small text-secondary';
lbl.style.minWidth = '200px';
lbl.style.textAlign = 'right';
lbl.htmlFor = 'aedit_' + f.key;
lbl.textContent = f.label;
var inp = document.createElement('input');
inp.className = 'form-control form-control-sm mono';
inp.style.maxWidth = '400px';
inp.id = 'aedit_' + f.key;
inp.value = currentAcct[f.key] || '';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
}
}
$('acctFlagsText').textContent = flagsLabel(parseInt(currentAcct.flags, 10) || 0);
setVerifiedStatus();
loadAcctCalls();
// Hide restricted actions for level 2
var isSuperAdmin = (typeof ADMIN_LEVEL !== 'undefined') && ADMIN_LEVEL >= 3;
$('btnBan').style.display = isSuperAdmin ? '' : 'none';
$('btnUnban').style.display = isSuperAdmin ? '' : 'none';
$('btnLock').style.display = isSuperAdmin ? '' : 'none';
$('btnUnlock').style.display = isSuperAdmin ? '' : 'none';
$('btnDelete').style.display = isSuperAdmin ? '' : 'none';
// Show edit, hide list
$('acctListPanel').style.display = 'none';
$('acctEditPanel').style.display = '';
switchTab('account');
setMsg($('acctMsg'), null);
}
function backToList() {
$('acctEditPanel').style.display = 'none';
$('acctListPanel').style.display = '';
currentUuid = null;
currentAcct = null;
}
/* ======== CALLSIGNS TAB (callsigns the account is admin for) ======== */
// Same dmr-style editor as the node-admins table: Add on top, editable rows
// with Delete, Save at the bottom diffing against the server.
async function loadAcctCalls() {
var box = $('acctCallsEditor');
if (!box || !currentUuid) return;
box.innerHTML = '<span class="small text-secondary">Loading…</span>';
var j = await api({action:'acct_calls_list', uuid:currentUuid});
if (!j || !j.ok) { box.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed') + '</span>'; return; }
var server = (j.callsigns || []).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 ac-add">Add</button></div>'
+ '<div class="ac-wrap mb-2"></div>'
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ac-save">Save</button></div>'
+ '<div class="ac-msg small mt-2" style="display:none;"></div>';
var tw = box.querySelector('.ac-wrap'), msg = box.querySelector('.ac-msg');
function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; }
function renderTable(){
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 (!working.length) h += '<tr><td colspan="2" class="small text-secondary">No extra callsigns — click Add. (The primary callsign always counts.)</td></tr>';
working.forEach(function(v, i){
h += '<tr><td><input type="text" class="form-control form-control-sm mono ac-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SK7AX" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ac-del" data-i="'+i+'">Delete</button></td></tr>';
});
tw.innerHTML = h + '</tbody></table></div>';
tw.querySelectorAll('.ac-cs').forEach(function(inp){ inp.addEventListener('input', function(){ working[+inp.dataset.i] = inp.value; }); });
tw.querySelectorAll('.ac-del').forEach(function(b){ b.addEventListener('click', function(){ working.splice(+b.dataset.i, 1); renderTable(); }); });
}
renderTable();
box.querySelector('.ac-add').addEventListener('click', function(){ working.push(''); renderTable(); });
box.querySelector('.ac-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 api({action:'acct_calls_remove', uuid:currentUuid, callsign: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 api({action:'acct_calls_add', uuid:currentUuid, callsign:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } }
server = norm.slice(); working = norm.slice(); renderTable();
setErr('✓ Saved', true);
});
}
/* ======== NODES TAB (per user) ======== */
async function loadEditNodes() {
if (!currentAcct) return;
var status = $('editNodesStatus');
var list = $('editNodesList');
// Fetch all connected nodes from reflector — by uuid so the account's extra
// callsigns (club calls) are matched too.
try {
var ch = await fetch('/auth/admin_nodes.php?uuid=' + encodeURIComponent(currentUuid), {credentials:'same-origin'});
var j = await ch.json();
if (!j || !j.ok) { status.textContent = j && j.error || 'Error'; return; }
editNodesLoaded = true;
editNodes = j.nodes || {};
var keys = Object.keys(editNodes);
// Populate selector
var sel = $('editNodeSelect');
sel.innerHTML = '';
if (keys.length > 1) {
var o = document.createElement('option');
o.value = '__all__';
o.textContent = 'All (' + keys.length + ')';
sel.appendChild(o);
}
for (var i = 0; i < keys.length; i++) {
var o = document.createElement('option');
o.value = keys[i];
o.textContent = keys[i];
sel.appendChild(o);
}
$('editNodeSelWrap').style.display = keys.length > 0 ? '' : 'none';
status.textContent = keys.length ? keys.length + ' node' + (keys.length > 1 ? 's' : '') : 'No nodes';
renderEditNodes('__all__');
} catch(e) { status.textContent = 'Failed'; }
loadEditNodeAuth();
}
// POST to the shared node-auth API for the account being edited (target = its uuid).
async function nauth(body) {
body = body || {};
body.target = currentUuid;
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; });
}
// Reflector login & certificate — same layout/actions as the profile page, but
// for the edited account (renewals, remove cert, accept/issue pending, password).
async function loadEditNodeAuth() {
var cont = $('editNodeAuthList');
if (!cont) return;
cont.innerHTML = '<span class="small text-secondary">Loading…</span>';
var j = await nauth({action:'list'});
if (!j || !j.ok) { cont.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed to load') + '</span>'; return; }
var list = j.nodes || [], pend = j.pending || [];
if (!list.length && !pend.length) { cont.innerHTML = '<span class="small text-secondary">No reflector logins or certificates are linked to this 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 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 act = tr.querySelector('.na-act');
if (n.cert) {
// Revoke — remove the cert; renewals kept so the node can re-issue.
// Allowed for callsign-admins and any site admin (lvl>=1).
var rv = document.createElement('button');
rv.type = 'button'; rv.className = 'btn btn-sm btn-outline-warning py-0 me-1'; rv.style.fontSize = '.75rem';
rv.textContent = 'Revoke'; rv.title = 'Remove the certificate; the node can request a new one (renewals kept)';
rv.addEventListener('click', function() {
if (!confirm('Revoke the certificate for ' + n.node + '?\nThe node can request a new one afterwards.')) return;
rv.disabled = true;
nauth({action:'self_revoke', node:n.node}).then(function(r) {
if (r && r.ok) loadEditNodeAuth(); else { rv.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); }
});
});
act.appendChild(rv);
// Delete cert — permanent full wipe. Site admins level 2-3 only.
if (typeof ADMIN_LEVEL !== 'undefined' && ADMIN_LEVEL >= 2) {
var dl = document.createElement('button');
dl.type = 'button'; dl.className = 'btn btn-sm btn-outline-danger py-0 me-1'; dl.style.fontSize = '.75rem';
dl.textContent = 'Delete cert'; dl.title = 'Permanently delete cert + pending CSR + renewal counters + node admins';
dl.addEventListener('click', function() {
if (!confirm('DELETE the certificate for ' + n.node + ' permanently?\nAlso clears pending CSR, renewal counters and node admins. The node cannot self-renew afterwards.')) return;
dl.disabled = true;
nauth({action:'delete_cert', node:n.node}).then(function(r) {
if (r && r.ok) loadEditNodeAuth(); else { dl.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); }
});
});
act.appendChild(dl);
}
}
if (n.password) {
var pwCell = tr.querySelector('.na-pw');
nauth({action:'getpw', node:n.node}).then(function(r) { pwCell.textContent = (r && r.ok) ? r.password : ((r && (r.detail||r.error)) || 'failed'); });
var rp = document.createElement('button');
rp.type = 'button'; rp.className = 'btn btn-sm btn-outline-secondary py-0'; rp.style.fontSize = '.75rem'; rp.textContent = 'Reset password';
rp.addEventListener('click', function() {
if (!confirm('Generate a NEW reflector password for ' + n.node + '?')) return;
rp.disabled = true; pwCell.textContent = 'Working…';
nauth({action:'resetpw', node:n.node}).then(function(r) { rp.disabled = false; pwCell.textContent = (r && r.ok) ? r.password : ((r && (r.detail||r.error)) || 'failed'); });
});
act.appendChild(rp);
}
});
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 act = tr.querySelector('.na-act');
if (p.canSelfSign) {
var ac = document.createElement('button');
ac.type = 'button'; ac.className = 'btn btn-sm btn-success py-0 me-1'; ac.style.fontSize = '.75rem'; ac.textContent = 'Accept / issue';
ac.addEventListener('click', function() {
if (!confirm('Issue the certificate for ' + p.node + '?')) return;
ac.disabled = true;
nauth({action:'self_sign', node:p.node}).then(function(r) { if (r && r.ok) loadEditNodeAuth(); else { ac.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); } });
});
act.appendChild(ac);
}
if (p.canReject) {
var rj = document.createElement('button');
rj.type = 'button'; rj.className = 'btn btn-sm btn-outline-danger py-0'; rj.style.fontSize = '.75rem'; rj.textContent = 'Reject';
rj.addEventListener('click', function() {
if (!confirm('Reject (delete) the pending request for ' + p.node + '?')) return;
rj.disabled = true;
rapi({action:'rmpending', callsign:p.node}).then(function(r) { if (r && r.ok) loadEditNodeAuth(); else { rj.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); } });
});
act.appendChild(rj);
}
});
}
function renderEditNodes(selected) {
var list = $('editNodesList');
list.innerHTML = '';
var keys = Object.keys(editNodes);
if (keys.length === 0) {
list.innerHTML = '<div class="text-secondary small">No nodes (connected, password or certificate) found for this callsign.</div>';
return;
}
for (var i = 0; i < keys.length; i++) {
if (selected !== '__all__' && keys[i] !== selected) continue;
list.appendChild(buildNodeCard(keys[i], editNodes[keys[i]]));
}
}
// dmr peer "Edit admins" editor: Add on top, a row per admin with an editable
// callsign field + Delete, Save at the bottom. Each node keeps its own admin
// list; on Save the edited list is diffed against the server and add/remove
// calls are issued. Site admins (this page is admin-only) may edit any node.
function buildNodeAdminEditor(box, cs, currentAdmins) {
var server = (currentAdmins || []).map(function(c){ return String(c).toUpperCase(); });
var working = server.slice();
box.innerHTML =
'<div class="small text-secondary mb-1">Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.</div>'
+ '<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');
function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; }
function renderTable(){
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 (!working.length) h += '<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){
h += '<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 = h + '</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', 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 api({action:'node_admin_remove', node:cs, admin_callsign: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 api({action:'node_admin_add', node:cs, admin_callsign:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } }
server = norm.slice();
setErr('✓ Saved', true);
});
}
function buildNodeCard(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.password) html += ' <span class="node-badge">password</span>';
if (n.cert) html += ' <span class="node-badge">cert</span>';
if (n.isTalker) html += ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">TX</span>';
if (n.offline) html += ' <span class="node-badge" style="border-color:#888;color:#888;">offline</span>';
html += '</h6>';
html += '<dl class="node-grid">';
if (n.nodeLocation) html += '<dt>Location</dt><dd>' + esc(n.nodeLocation) + '</dd>';
if (n.sysop) html += '<dt>Sysop</dt><dd>' + esc(n.sysop) + '</dd>';
if (n.sw) html += '<dt>Software</dt><dd>' + esc(n.sw + ' ' + (n.swVer||'')) + '</dd>';
if (n.projVer) html += '<dt>Build</dt><dd>' + esc(n.projVer) + '</dd>';
if (n.machineArch) html += '<dt>Arch</dt><dd>' + esc(n.machineArch) + '</dd>';
if (n.protoVer) html += '<dt>Protocol</dt><dd>v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer + '</dd>';
if (n.tg !== undefined) html += '<dt>Current TG</dt><dd>' + esc(String(n.tg || 'None')) + '</dd>';
html += '</dl>';
// Node admins — dmr peer "Edit admins" table editor, mounted after innerHTML.
html += '<div class="mt-2 na-adm-mount"></div>';
// Monitored TGs
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>';
}
// CTCSS Tone Mapping
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>';
}
// QTH
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</dt><dd>' + esc(q.pos.loc) + '</dd>';
if (q.pos.lat) html += '<dt>Lat/Long</dt><dd>' + esc(q.pos.lat + ', ' + q.pos.long) + '</dd>';
}
if (q.rx) {
for (var id in q.rx) {
var rx = q.rx[id];
html += '<dt>RX ' + esc(id) + '</dt><dd>' + esc((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':'')) + '</dd>';
html += '<dt>Status</dt><dd>' + esc((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':'')) + '</dd>';
}
}
if (q.tx) {
for (var id in q.tx) {
var tx = q.tx[id];
html += '<dt>TX ' + esc(id) + '</dt><dd>' + esc((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':'')) + '</dd>';
html += '<dt>Transmitting</dt><dd>' + esc(tx.transmit?'Yes':'No') + '</dd>';
}
}
html += '</dl>';
html += '</div>';
}
}
// Certificate & reflector login are shown in the "Reflector login & certificate"
// panel above (loadEditNodeAuth) — same layout as the profile page.
card.innerHTML = html;
// Mount the dmr-style multi-admin editor (Add / editable rows / Delete / Save).
var mount = card.querySelector('.na-adm-mount');
if (mount) buildNodeAdminEditor(mount, callsign, n.node_admins || []);
return card;
}
function renderPagination(total, totalPages) {
var wrap = $('paginationWrap');
if (!wrap) return;
if (totalPages <= 1) { wrap.innerHTML = '<span class="small text-secondary">' + total + ' accounts</span>'; return; }
var html = '<nav class="d-flex align-items-center gap-1 flex-wrap" style="font-size:.8rem;">';
html += '<span class="small text-secondary me-2">' + total + ' accounts, page ' + currentPage + '/' + totalPages + '</span>';
// First + Prev
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="1" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">&laquo;</button>';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage-1) + '" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">&lsaquo;</button>';
// Page numbers — show range around current
var rangeStart = Math.max(1, currentPage - 3);
var rangeEnd = Math.min(totalPages, currentPage + 3);
for (var p = rangeStart; p <= rangeEnd; p++) {
var active = p === currentPage ? 'background:rgba(0,255,255,.2);' : '';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + p + '" style="padding:2px 8px;' + active + '">' + p + '</button>';
}
// Next + Last
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage+1) + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">&rsaquo;</button>';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + totalPages + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">&raquo;</button>';
html += '</nav>';
wrap.innerHTML = html;
wrap.querySelectorAll('.pg-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var pg = parseInt(btn.dataset.pg, 10);
if (pg >= 1 && pg <= totalPages && pg !== currentPage) {
currentPage = pg;
renderAccounts($('acctSearch').value);
}
});
});
}
/* ======== INIT ======== */
document.addEventListener('DOMContentLoaded', function() {
// Load CSRF token
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;
if(j&&typeof j.flags!=='undefined') window._actingFlags=(j.flags|0);
}).catch(function(){});
loadAccounts();
$('acctSearch').addEventListener('input', function(e) { currentPage = 1; renderAccounts(e.target.value); });
$('btnBack').addEventListener('click', backToList);
// Tab switching
document.querySelectorAll('.profile-tab[data-ptab]').forEach(function(btn) {
btn.addEventListener('click', function() { switchTab(btn.dataset.ptab); });
});
// Node instance selector
$('editNodeSelect').addEventListener('change', function() { renderEditNodes(this.value); });
// Account actions
$('btnSave').addEventListener('click', async function() {
if (!currentUuid) return;
var body = {action:'update', uuid:currentUuid};
['callsign','firstname','lastname','shortname','email','city','state','country'].forEach(function(f) {
var el = $('aedit_'+f); if (el) body[f] = el.value;
});
var j = await api(body);
if (j && j.ok) { setMsg($('acctMsg'),'Saved!',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnBan').addEventListener('click', async function() {
if (!currentUuid) return;
var reason = prompt('Ban reason (optional):') || '';
var j = await api({action:'ban',uuid:currentUuid,reason:reason});
if (j&&j.ok) { setMsg($('acctMsg'),'Banned',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnban').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'unban',uuid:currentUuid});
if (j&&j.ok) { setMsg($('acctMsg'),'Unbanned',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnLock').addEventListener('click', async function() {
if (!currentUuid) return;
var reason = prompt('Lock reason (optional):') || '';
var j = await api({action:'lock',uuid:currentUuid,reason:reason});
if (j&&j.ok) { setMsg($('acctMsg'),'Locked',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnlock').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'unlock',uuid:currentUuid});
if (j&&j.ok) { setMsg($('acctMsg'),'Unlocked',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnVerifyEmail').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'set_verified', uuid:currentUuid, verified:true});
if (j&&j.ok) { currentAcct.email_verified='1'; setVerifiedStatus(); setMsg($('acctMsg'),'Email marked verified',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnverifyEmail').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'set_verified', uuid:currentUuid, verified:false});
if (j&&j.ok) { currentAcct.email_verified='0'; setVerifiedStatus(); setMsg($('acctMsg'),'Email set to unverified',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
// One-click approve/unapprove — same net effect as the flags modal, for when
// an account was missed in the bell. Approve sets reg(2)+normal(4); unapprove
// drops normal but keeps reg. Mirrors the dmr Register/Unregister buttons.
$('btnApprove').addEventListener('click', async function() {
if (!currentUuid || !currentAcct) return;
var flags = ((parseInt(currentAcct.flags, 10) || 0) | 2 | 4) & 0x3F;
var j = await api({action:'set_flags', uuid:currentUuid, flags:flags});
if (j&&j.ok) { currentAcct.flags = j.flags; $('acctFlagsText').textContent = flagsLabel(j.flags); setApprovalBadge(); setMsg($('acctMsg'),'Approved — user can log in',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnapprove').addEventListener('click', async function() {
if (!currentUuid || !currentAcct) return;
var flags = ((parseInt(currentAcct.flags, 10) || 0) & ~4) & 0x3F;
var j = await api({action:'set_flags', uuid:currentUuid, flags:flags});
if (j&&j.ok) { currentAcct.flags = j.flags; $('acctFlagsText').textContent = flagsLabel(j.flags); setApprovalBadge(); setMsg($('acctMsg'),'Approval revoked — user cannot log in',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnDelete').addEventListener('click', async function() {
if (!currentUuid) return;
if (!confirm('Permanently delete this account?')) return;
var j = await api({action:'delete',uuid:currentUuid});
if (j&&j.ok) { backToList(); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnAcctLevel').addEventListener('click', function() {
if (!currentUuid || !currentAcct) return;
openFlagsModal(currentUuid, currentAcct.callsign || '', parseInt(currentAcct.flags, 10) || 0, String(currentAcct.email_verified || '0') === '1', function(nf) {
currentAcct.flags = nf;
$('acctFlagsText').textContent = flagsLabel(nf);
setApprovalBadge();
setMsg($('acctMsg'),'Saved',false);
loadAccounts();
});
});
$('btnFlagsSave').addEventListener('click', async function() {
var st = flagsModalState;
if (!st) return;
var j = await api({action:'set_flags', uuid:st.uuid, flags:st.pending});
if (j && j.ok) {
st.flags = j.flags;
st.pending = j.flags;
st.onChange(st.flags);
var m = window.bootstrap && bootstrap.Modal.getInstance($('acctFlagsModal'));
if (m) m.hide();
} else {
setMsg($('flagsModalMsg'), (j && j.error) || 'Failed', true);
}
});
$('btnResetPwd').addEventListener('click', async function() {
if (!currentUuid) return;
var p = $('newPwd').value;
if (!p || p.length < 6) { setMsg($('pwdMsg'),'Min 6 chars',true); return; }
var j = await api({action:'reset_password',uuid:currentUuid,new_password:p});
if (j&&j.ok) { setMsg($('pwdMsg'),'Password reset',false); $('newPwd').value=''; }
else setMsg($('pwdMsg'),(j&&j.error)||'Failed',true);
});
});