857 lines
43 KiB
JavaScript
857 lines
43 KiB
JavaScript
// Reflector Dashboard — web front-end for SvxReflector
|
||
// Copyright (C) 2026 SA7AUX (Björn)
|
||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify it under
|
||
// the terms of the GNU Affero General Public License as published by the Free
|
||
// Software Foundation, either version 3 of the License, or (at your option) any
|
||
// later version. See the LICENSE file for details.
|
||
|
||
'use strict';
|
||
|
||
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;">«</button>';
|
||
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage-1) + '" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">‹</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;">›</button>';
|
||
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + totalPages + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">»</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);
|
||
});
|
||
|
||
});
|