// 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'; // Admin TG editor (List B) — mirrors the DMR sysconfig SVX-satellite editor: // list (data-table) -> per-row Edit -> grouped-field detail form -> an // "Edit admins (N)" button that drills into an admins editor (owner shown // first as always-admin, then co-admins with Add/Delete + Save/Back), exactly // like renderSvxSatelliteEdit_ + renderAdminsEditor_. Backed by tglist_api.php. var $ = function (id) { return document.getElementById(id); }; var tgaData = [], tgaPending = [], tgaSearch = '', tgaSortKey = 'tg', tgaSortDir = 'asc'; var tgaSelected = new Set(); // selected TG numbers for bulk actions function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; } function tgaMsg(t, err) { var e = $('tgadminMsg'); if (!e) return; if (!t) { e.style.display = 'none'; return; } e.style.display = 'block'; e.textContent = t; e.style.color = err ? '#ff8080' : '#0f0'; } async function tgaApi(body) { var r = await fetch('/auth/tglist_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; }); } // regex filter with literal fallback (never throws) function tgaMatcher(q) { q = (q || '').trim(); if (!q) return function () { return true; }; var re; try { re = new RegExp(q, 'i'); } catch (e) { var lit = q.toLowerCase(); return function (s) { return s.toLowerCase().indexOf(lit) >= 0; }; } return function (s) { return re.test(s); }; } async function tgaLoad() { var j = await tgaApi({ action: 'list' }); if (!j || !j.ok) { tgaMsg((j && j.error) || 'Load failed', true); return; } tgaData = j.tgs || []; var p = await tgaApi({ action: 'pending_list' }); tgaPending = (p && p.ok && p.pending) ? p.pending : []; renderList(); } function pendingHtml() { if (!tgaPending.length) return ''; var h = '
Pending requests (' + tgaPending.length + ')
' + '
'; tgaPending.forEach(function (p) { h += '' + '' + '' + ''; }); return h + '
TGNameSystemAllStarModeRequesterActions
' + esc(p.tg) + '' + esc(p.name) + '' + esc(p.system) + '' + esc(p.allstar) + '' + esc(p.mode) + '' + esc(p.callsign) + '' + ' ' + '' + '
'; } async function tgaApprove(uuid) { var res = await tgaApi({ action: 'approve', uuid: uuid }); if (res && res.ok) { tgaMsg('Approved — created TG ' + res.tg, false); await tgaLoad(); } else tgaMsg((res && res.error) || 'Failed', true); } async function tgaDeny(uuid) { if (!confirm('Deny this TG request?')) return; var res = await tgaApi({ action: 'deny', uuid: uuid }); if (res && res.ok) { tgaMsg('Denied.', false); await tgaLoad(); } else tgaMsg((res && res.error) || 'Failed', true); } function rowByTg(tg) { var n = parseInt(tg, 10); return tgaData.filter(function (r) { return r.tg === n; })[0]; } // ── list view (data-table, like renderArrList) ─────────────────────────── function renderList() { var app = $('tgadminApp'); if (!app) return; var match = tgaMatcher(tgaSearch); var rows = tgaData.filter(function (r) { return match([r.tg, r.name, r.system, r.allstar, r.mode, r.owner].join(' ')); }); var key = tgaSortKey, dir = tgaSortDir === 'desc' ? -1 : 1; rows.sort(function (a, b) { var x = a[key], y = b[key]; if (key === 'tg') return (x - y) * dir; x = (x || '').toLowerCase(); y = (y || '').toLowerCase(); return (x < y ? -1 : x > y ? 1 : 0) * dir; }); var h = pendingHtml() + '
' + '
TG List — admin (' + rows.length + '/' + tgaData.length + ')
' + '
' + '
' + '
' + '
' + '' + '' + '' + '
' + (tgaSelected.size ? '
' + tgaSelected.size + ' selected
' : '') + '
' + ''; rows.forEach(function (r) { h += '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }); h += '
TGNameSystemAllStarModeOwnerAdminsActions
' + esc(r.tg) + '' + esc(r.name) + '' + esc(r.system) + '' + esc(r.allstar) + '' + esc(r.mode) + '' + (r.owner ? esc(r.owner) : '—') + '' + ((r.admins && r.admins.length) ? r.admins.length : '0') + '' + ' ' + '' + '
'; if (!tgaData.length) h += '

No talkgroups yet — click + Add TG.

'; app.innerHTML = h; $('tgaRefresh').addEventListener('click', tgaLoad); $('tgaAdd').addEventListener('click', function () { renderDetail(null); }); var s = $('tgaSearch'); s.addEventListener('input', function () { tgaSearch = s.value; var pos = s.selectionStart; renderList(); var s2 = $('tgaSearch'); if (s2) { s2.focus(); try { s2.setSelectionRange(pos, pos); } catch (e) {} } }); $('tgaSortKey').value = tgaSortKey; $('tgaSortKey').addEventListener('change', function () { tgaSortKey = this.value; renderList(); }); $('tgaSortDir').addEventListener('click', function () { tgaSortDir = tgaSortDir === 'asc' ? 'desc' : 'asc'; renderList(); }); app.querySelectorAll('button[data-tga-edit]').forEach(function (b) { b.addEventListener('click', function () { var r = rowByTg(b.getAttribute('data-tga-edit')); if (r) renderDetail(r); }); }); app.querySelectorAll('button[data-tga-del]').forEach(function (b) { b.addEventListener('click', function () { tgaDelete(b.getAttribute('data-tga-del')); }); }); app.querySelectorAll('button[data-tga-approve]').forEach(function (b) { b.addEventListener('click', function () { tgaApprove(b.getAttribute('data-tga-approve')); }); }); app.querySelectorAll('button[data-tga-deny]').forEach(function (b) { b.addEventListener('click', function () { tgaDeny(b.getAttribute('data-tga-deny')); }); }); // Multi-select + bulk delete (like the SVX-satellite TG-map editor). var shownTgs = rows.map(function (r) { return r.tg; }); var selAll = $('tgaSelAll'); if (selAll) { selAll.checked = shownTgs.length > 0 && shownTgs.every(function (tg) { return tgaSelected.has(tg); }); selAll.addEventListener('change', function () { if (selAll.checked) shownTgs.forEach(function (tg) { tgaSelected.add(tg); }); else shownTgs.forEach(function (tg) { tgaSelected.delete(tg); }); renderList(); }); } app.querySelectorAll('input.tga-sel').forEach(function (cb) { cb.addEventListener('change', function () { var tg = parseInt(cb.getAttribute('data-tga-sel'), 10); if (cb.checked) tgaSelected.add(tg); else tgaSelected.delete(tg); renderList(); }); }); var bulk = $('tgaBulkDel'); if (bulk) bulk.addEventListener('click', tgaBulkDelete); } async function tgaBulkDelete() { var tgs = Array.from(tgaSelected); if (!tgs.length) return; if (!confirm('Delete ' + tgs.length + ' selected TG' + (tgs.length === 1 ? '' : 's') + '?')) return; for (var i = 0; i < tgs.length; i++) { await tgaApi({ action: 'delete', tg: tgs[i] }); } tgaSelected.clear(); tgaMsg('Deleted ' + tgs.length + ' TG' + (tgs.length === 1 ? '' : 's'), false); await tgaLoad(); } // ── detail view: grouped labeled fields (like renderSvxSatelliteEdit_) ──── function fieldRow(label, inputHtml) { return '
' + '' + inputHtml + '
'; } function groupHead(t) { return '
' + esc(t) + '
'; } function renderDetail(row) { var app = $('tgadminApp'); if (!app) return; var isNew = !row; var r = row || { tg: '', name: '', system: '', allstar: '', mode: '', owner: '', admins: [] }; var inp = function (id, val, extra) { return ''; }; var h = '
' + '
' + (isNew ? 'Add TG' : ('Edit: TG ' + esc(r.tg) + (r.name ? ' — ' + esc(r.name) : ''))) + '
' + '' + '
' + groupHead('Identity') + fieldRow('tg', inp('tgaf_tg', r.tg, 'type="number"' + (isNew ? '' : ' disabled'))) + fieldRow('name', inp('tgaf_name', r.name, 'maxlength="64"')) + groupHead('Location') + fieldRow('system', inp('tgaf_system', r.system, 'maxlength="64"')) + fieldRow('allstar', inp('tgaf_allstar', r.allstar, 'maxlength="10"')) + fieldRow('mode', inp('tgaf_mode', r.mode, 'maxlength="16" placeholder="AllStar / DMR / SVX"')) + groupHead('Ownership') + fieldRow('owner', '' + (r.owner ? esc(r.owner) : '— none (set when a TG request is approved) —') + '') + fieldRow('admins', '') + '
' + '' + (isNew ? '' : '') + '' + '
'; app.innerHTML = h; $('tgaBack').addEventListener('click', renderList); $('tgaCancel').addEventListener('click', renderList); $('tgaSave').addEventListener('click', tgaSave); var del = $('tgaDel'); if (del) del.addEventListener('click', function () { tgaDelete(r.tg); }); var ea = $('tgaEditAdmins'); if (ea && !isNew) ea.addEventListener('click', function () { renderAdmins(r); }); } async function tgaSave() { var tg = parseInt($('tgaf_tg').value, 10); if (!tg || tg <= 0) { tgaMsg('TG must be a positive number', true); return; } var res = await tgaApi({ action: 'save', tg: tg, name: $('tgaf_name').value, system: $('tgaf_system').value, allstar: ($('tgaf_allstar').value || '').trim(), mode: $('tgaf_mode').value }); if (res && res.ok) { tgaMsg('Saved TG ' + tg, false); await tgaLoad(); } else tgaMsg((res && res.error) || 'Failed', true); } async function tgaDelete(tg) { if (!confirm('Delete TG ' + tg + '?')) return; var res = await tgaApi({ action: 'delete', tg: parseInt(tg, 10) }); if (res && res.ok) { tgaMsg('Deleted TG ' + tg, false); await tgaLoad(); } else tgaMsg((res && res.error) || 'Failed', true); } // ── admins drill-down (like renderAdminsEditor_) ───────────────────────── function renderAdmins(row) { var app = $('tgadminApp'); if (!app) return; var owner = (row.owner || '').toUpperCase(); var working = (row.admins || []).map(function (c) { return String(c); }); var draw = function () { var body = ''; // Owner row: always an admin, shown first with a badge, not removable here. if (owner) { body += '' + esc(owner) + ' owner'; } if (working.length === 0 && !owner) { body += 'No admins — click Add to grant a callsign admin rights for this TG. (Account admin level always has access.)'; } working.forEach(function (cs, i) { body += '' + '' + '' + ''; }); var h = '
' + '
Admins (callsigns) — TG ' + esc(row.tg) + '
' + '' + '
' + '
' + '
' + body + '
CallsignAction
' + '
' + '' + '' + '
'; app.innerHTML = h; var back = function () { renderDetail(row); }; $('admBackTop').addEventListener('click', back); $('admBack').addEventListener('click', back); $('admAdd').addEventListener('click', function () { working.push(''); draw(); }); app.querySelectorAll('input[data-adm-i]').forEach(function (el) { el.addEventListener('input', function () { working[parseInt(el.getAttribute('data-adm-i'), 10)] = el.value; }); }); app.querySelectorAll('button[data-adm-del]').forEach(function (b) { b.addEventListener('click', function () { working.splice(parseInt(b.getAttribute('data-adm-del'), 10), 1); draw(); }); }); $('admSave').addEventListener('click', async function () { var seen = {}, list = []; working.forEach(function (c) { c = String(c).trim().toUpperCase(); if (c && !seen[c]) { seen[c] = 1; list.push(c); } }); var res = await tgaApi({ action: 'admin_set', tg: row.tg, admins: list }); if (res && res.ok) { row.admins = res.admins || list; tgaMsg('Admins saved for TG ' + row.tg, false); await tgaLoad(); renderDetail(rowByTg(row.tg) || row); } else tgaMsg((res && res.error) || 'Failed', true); }); }; draw(); } document.addEventListener('DOMContentLoaded', function () { 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; }) .catch(function () {}) .finally(tgaLoad); });