// 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)+'' + '' + '' + '' + '' + '' + ''; 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 = '
' + '
' + '
' + ''; 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 = '
'; if (!working.length) h += ''; working.forEach(function(v, i){ h += '' + ''; }); tw.innerHTML = h + '
CallsignAction
No extra callsigns — click Add. (The primary callsign always counts.)
'; 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 = '
CallsignAuthPasswordActions
'; 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.
' + '
' + '
' + '
' + ''; 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 = '
'; if (!working.length) h += ''; working.forEach(function(v, i){ h += '' + ''; }); tw.innerHTML = h + '
CallsignAction
No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)
'; 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 += ''; } html += '
' + sorted[i][0] + ' HzTG ' + sorted[i][1] + '
'; } // 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 = ''; 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); }); });