Files
reflector-dashboard/auth/tgadmin.js
T

297 lines
17 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';
// 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 = '<div class="mb-4"><h6 class="mb-2" style="color:var(--fg2);">Pending requests (' + tgaPending.length + ')</h6>' +
'<div style="overflow-x:auto;"><table class="data-table"><thead><tr><th>TG</th><th>Name</th><th>System</th><th>AllStar</th><th>Mode</th><th>Requester</th><th>Actions</th></tr></thead><tbody>';
tgaPending.forEach(function (p) {
h += '<tr>' +
'<td class="mono">' + esc(p.tg) + '</td><td>' + esc(p.name) + '</td><td>' + esc(p.system) + '</td>' +
'<td class="mono">' + esc(p.allstar) + '</td><td>' + esc(p.mode) + '</td><td class="mono">' + esc(p.callsign) + '</td>' +
'<td style="white-space:nowrap;">' +
'<button class="btn btn-success btn-sm" data-tga-approve="' + esc(p.uuid) + '">Approve</button> ' +
'<button class="btn btn-outline-danger btn-sm" data-tga-deny="' + esc(p.uuid) + '">Deny</button>' +
'</td></tr>';
});
return h + '</tbody></table></div></div>';
}
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() +
'<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">' +
'<h5 class="mb-0">TG List — admin <span class="text-secondary small">(' + rows.length + '/' + tgaData.length + ')</span></h5>' +
'<div class="d-flex gap-2"><button class="btn btn-outline-secondary btn-sm" id="tgaRefresh">Refresh</button>' +
'<button class="btn btn-outline-success btn-sm" id="tgaAdd">+ Add TG</button></div>' +
'</div>' +
'<div class="d-flex gap-2 mb-3 flex-wrap align-items-center">' +
'<input type="text" class="form-control form-control-sm mono" id="tgaSearch" placeholder="search … (regex ok)" style="max-width:300px;" value="' + esc(tgaSearch) + '">' +
'<select class="form-select form-select-sm" id="tgaSortKey" style="width:auto;"><option value="tg">Sort: TG</option><option value="name">Sort: Name</option><option value="system">Sort: System</option><option value="owner">Sort: Owner</option></select>' +
'<button class="btn btn-outline-secondary btn-sm" id="tgaSortDir">' + (tgaSortDir === 'asc' ? '&#9650; asc' : '&#9660; desc') + '</button>' +
'</div>' +
(tgaSelected.size
? '<div class="d-flex gap-2 mb-2 align-items-center"><button class="btn btn-danger btn-sm" id="tgaBulkDel">Delete selected</button><span class="small text-secondary">' + tgaSelected.size + ' selected</span></div>'
: '') +
'<div style="overflow-x:auto;"><table class="data-table">' +
'<thead><tr><th style="width:28px;"><input type="checkbox" id="tgaSelAll"></th><th>TG</th><th>Name</th><th>System</th><th>AllStar</th><th>Mode</th><th>Owner</th><th>Admins</th><th>Actions</th></tr></thead><tbody>';
rows.forEach(function (r) {
h += '<tr>' +
'<td><input type="checkbox" class="tga-sel" data-tga-sel="' + esc(r.tg) + '"' + (tgaSelected.has(r.tg) ? ' checked' : '') + '></td>' +
'<td class="mono">' + esc(r.tg) + '</td>' +
'<td>' + esc(r.name) + '</td>' +
'<td>' + esc(r.system) + '</td>' +
'<td class="mono">' + esc(r.allstar) + '</td>' +
'<td>' + esc(r.mode) + '</td>' +
'<td class="mono">' + (r.owner ? esc(r.owner) : '<span class="text-secondary">—</span>') + '</td>' +
'<td>' + ((r.admins && r.admins.length) ? r.admins.length : '<span class="text-secondary">0</span>') + '</td>' +
'<td style="white-space:nowrap;">' +
'<button class="btn btn-outline-secondary btn-sm" data-tga-edit="' + esc(r.tg) + '">Edit</button> ' +
'<button class="btn btn-outline-danger btn-sm" data-tga-del="' + esc(r.tg) + '">Delete</button>' +
'</td></tr>';
});
h += '</tbody></table></div>';
if (!tgaData.length) h += '<p class="small text-secondary mt-2">No talkgroups yet — click <b>+ Add TG</b>.</p>';
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 '<div class="d-flex gap-2 align-items-center mb-1">' +
'<label class="small text-secondary" style="min-width:160px;text-align:right;">' + esc(label) + '</label>' +
inputHtml + '</div>';
}
function groupHead(t) { return '<h6 class="mt-3 mb-2" style="color:var(--fg2);">' + esc(t) + '</h6>'; }
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 '<input id="' + id + '" class="form-control form-control-sm mono" style="max-width:360px;" ' + (extra || '') + ' value="' + esc(val) + '">'; };
var h =
'<div class="d-flex justify-content-between align-items-center mb-3">' +
'<h5 class="mb-0">' + (isNew ? 'Add TG' : ('Edit: TG ' + esc(r.tg) + (r.name ? ' — ' + esc(r.name) : ''))) + '</h5>' +
'<button class="btn btn-outline-secondary btn-sm" id="tgaBack">Back</button>' +
'</div>' +
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', '<span class="mono" style="align-self:center;">' + (r.owner ? esc(r.owner) : '<span class="text-secondary">— none (set when a TG request is approved) —</span>') + '</span>') +
fieldRow('admins', '<button type="button" class="btn btn-sm btn-outline-secondary" id="tgaEditAdmins"' + (isNew ? ' disabled title="Save the TG first"' : '') + '>Edit admins (' + ((r.admins && r.admins.length) || 0) + ')</button>') +
'<div class="d-flex gap-2 mt-3">' +
'<button class="btn btn-success btn-sm" id="tgaSave">Save</button>' +
(isNew ? '' : '<button class="btn btn-outline-danger btn-sm" id="tgaDel">Delete</button>') +
'<button class="btn btn-outline-secondary btn-sm" id="tgaCancel">Cancel</button>' +
'</div>';
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 += '<tr><td class="mono">' + esc(owner) + ' <span class="badge bg-info text-dark small">owner</span></td><td></td></tr>';
}
if (working.length === 0 && !owner) {
body += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this TG. (Account admin level always has access.)</td></tr>';
}
working.forEach(function (cs, i) {
body += '<tr>' +
'<td><input type="text" class="form-control form-control-sm mono" data-adm-i="' + i + '" value="' + esc(cs) + '" placeholder="SA7AUX" style="text-transform:uppercase;max-width:220px;"></td>' +
'<td style="width:120px;"><button class="btn btn-sm btn-outline-danger" data-adm-del="' + i + '">Delete</button></td>' +
'</tr>';
});
var h =
'<div class="d-flex justify-content-between align-items-center mb-2">' +
'<h6 class="mb-0" style="color:var(--fg2);">Admins (callsigns) — TG ' + esc(row.tg) + '</h6>' +
'<button class="btn btn-outline-secondary btn-sm" id="admBackTop">Back</button>' +
'</div>' +
'<div class="mb-2"><button type="button" class="btn btn-sm btn-outline-success" id="admAdd">Add</button></div>' +
'<div style="overflow-x:auto;"><table class="data-table"><thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>' +
body + '</tbody></table></div>' +
'<div class="d-flex gap-2 mt-3">' +
'<button class="btn btn-primary btn-sm" id="admSave">Save</button>' +
'<button class="btn btn-outline-secondary btn-sm ms-auto" id="admBack">Back</button>' +
'</div>';
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);
});