// SvxPortal — 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 ? 'BANNED '
: (!a.verified ? 'Unverified '
: (approved ? 'Active ' : 'Pending approval '));
var tr = document.createElement('tr');
tr.innerHTML = '
'+esc(a.callsign||'')+' '+esc((a.firstname||'')+' '+(a.lastname||''))+' '+esc(a.email||'')+' '+esc(flagsLabel(a.flags||0))+' '+status+' '+fmtTime(a.last_login)+' '
+ 'Admin '
+ 'Edit '
+ 'Delete '
+ 'Ban '
+ 'Unban '
+ ' ';
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 ? 'Verified ' : 'Not verified ';
}
// 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 = 'Loading… ';
var j = await api({action:'acct_calls_list', uuid:currentUuid});
if (!j || !j.ok) { box.innerHTML = '' + esc((j && j.error) || 'Failed') + ' '; return; }
var server = (j.callsigns || []).map(function(c){ return String(c).toUpperCase(); });
var working = server.slice();
box.innerHTML =
'Add
'
+ '
'
+ 'Save
'
+ '
';
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 = '';
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 = 'Loading… ';
var j = await nauth({action:'list'});
if (!j || !j.ok) { cont.innerHTML = '' + esc((j && j.error) || 'Failed to load') + ' '; return; }
var list = j.nodes || [], pend = j.pending || [];
if (!list.length && !pend.length) { cont.innerHTML = 'No reflector logins or certificates are linked to this account. '; return; }
cont.innerHTML = 'Callsign Auth Password Actions
';
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('cert' + (n.validTo ? ' · ' + new Date(n.validTo*1000).toISOString().slice(0,10) : '') + ' ' + (expired ? ' EXPIRED ' : ''));
if (typeof n.renewals !== 'undefined') auth.push('renewals: ' + (n.renewals|0) + ' ');
}
if (n.password) auth.push('password ');
var tr = document.createElement('tr');
tr.innerHTML = '' + esc(n.node) + ' ' + auth.join(' ') + ' '
+ '' + (n.password ? 'Loading…' : '— ') + ' '
+ ' ';
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 = '' + esc(p.node) + ' '
+ 'cert request ' + (typeof p.renewals !== 'undefined' ? ' renewals: ' + (p.renewals|0) + ' ' : '') + ' '
+ '— ';
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 = 'No nodes (connected, password or certificate) found for this callsign.
';
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 =
'Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.
'
+ 'Add
'
+ '
'
+ 'Save
'
+ '
';
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 = '';
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 = '' + esc(callsign) + ' ';
if (n.nodeClass) html += ' ' + esc(n.nodeClass) + ' ';
if (n.password) html += ' password ';
if (n.cert) html += ' cert ';
if (n.isTalker) html += ' TX ';
if (n.offline) html += ' offline ';
html += ' ';
html += '';
if (n.nodeLocation) html += 'Location ' + esc(n.nodeLocation) + ' ';
if (n.sysop) html += 'Sysop ' + esc(n.sysop) + ' ';
if (n.sw) html += 'Software ' + esc(n.sw + ' ' + (n.swVer||'')) + ' ';
if (n.projVer) html += 'Build ' + esc(n.projVer) + ' ';
if (n.machineArch) html += 'Arch ' + esc(n.machineArch) + ' ';
if (n.protoVer) html += 'Protocol v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer + ' ';
if (n.tg !== undefined) html += 'Current TG ' + esc(String(n.tg || 'None')) + ' ';
html += ' ';
// Node admins — dmr peer "Edit admins" table editor, mounted after innerHTML.
html += '
';
// Monitored TGs
if (n.monitoredTGs && n.monitoredTGs.length > 0) {
html += 'Monitored TGs: ';
for (var i = 0; i < n.monitoredTGs.length; i++) html += '' + n.monitoredTGs[i] + ' ';
html += '
';
}
// CTCSS Tone Mapping
if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) {
html += 'CTCSS Tone Mapping: ';
html += '
';
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 += '' + sorted[i][0] + ' Hz TG ' + sorted[i][1] + ' ';
}
html += '
';
}
// QTH
if (n.qth && n.qth.length > 0) {
for (var qi = 0; qi < n.qth.length; qi++) {
var q = n.qth[qi];
html += '';
html += '
QTH: ' + esc(q.name || '') + ' ';
html += '
';
if (q.pos) {
if (q.pos.loc) html += 'Locator ' + esc(q.pos.loc) + ' ';
if (q.pos.lat) html += 'Lat/Long ' + esc(q.pos.lat + ', ' + q.pos.long) + ' ';
}
if (q.rx) {
for (var id in q.rx) {
var rx = q.rx[id];
html += 'RX ' + esc(id) + ' ' + esc((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':'')) + ' ';
html += 'Status ' + esc((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':'')) + ' ';
}
}
if (q.tx) {
for (var id in q.tx) {
var tx = q.tx[id];
html += 'TX ' + esc(id) + ' ' + esc((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':'')) + ' ';
html += 'Transmitting ' + esc(tx.transmit?'Yes':'No') + ' ';
}
}
html += ' ';
html += '
';
}
}
// 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 = '' + total + ' accounts '; return; }
var html = '';
html += '' + total + ' accounts, page ' + currentPage + '/' + totalPages + ' ';
// First + Prev
html += '« ';
html += '‹ ';
// 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 += '' + p + ' ';
}
// Next + Last
html += '› ';
html += '» ';
html += ' ';
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);
});
});