674 lines
32 KiB
JavaScript
674 lines
32 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';
|
||
|
||
(function(){
|
||
function getCookie(name){
|
||
const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.$?*|{}()[\]\\\/\+^]/g, '\\$&') + '=([^;]*)'));
|
||
return m ? decodeURIComponent(m[1]) : null;
|
||
}
|
||
|
||
async function setConsent(v){
|
||
try{
|
||
await fetch('/auth/consent.php', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({consent: v})
|
||
});
|
||
}catch(_){}
|
||
document.cookie = 'cookie_consent=' + encodeURIComponent(v) + '; Path=/; Max-Age=' + (60*60*24*365) + '; SameSite=Lax';
|
||
}
|
||
|
||
function showCookieModalIfNeeded(){
|
||
const v = getCookie('cookie_consent');
|
||
if(v === 'necessary' || v === 'all') return;
|
||
const el = document.getElementById('cookieModal');
|
||
if(!el || !window.bootstrap) return;
|
||
const m = new bootstrap.Modal(el, {backdrop:'static', keyboard:false});
|
||
m.show();
|
||
|
||
document.getElementById('btnCookiesNecessary')?.addEventListener('click', async () => {
|
||
await setConsent('necessary');
|
||
m.hide();
|
||
}, {once:true});
|
||
|
||
document.getElementById('btnCookiesAll')?.addEventListener('click', async () => {
|
||
await setConsent('all');
|
||
m.hide();
|
||
}, {once:true});
|
||
}
|
||
|
||
function showSignin(){
|
||
const el = document.getElementById('signinModal');
|
||
if(!el || !window.bootstrap) return;
|
||
const m = new bootstrap.Modal(el);
|
||
m.show();
|
||
document.getElementById('signinUser')?.focus();
|
||
}
|
||
|
||
|
||
function setResend(show, user, pass){
|
||
let btn = document.getElementById('signinResend');
|
||
if(!show){ if(btn) btn.remove(); return; }
|
||
if(!btn){
|
||
const err = document.getElementById('signinErr');
|
||
if(!err || !err.parentNode) return;
|
||
btn = document.createElement('button');
|
||
btn.id = 'signinResend';
|
||
btn.type = 'button';
|
||
btn.className = 'btn btn-outline-secondary btn-sm mt-2';
|
||
btn.textContent = 'Resend verification email';
|
||
err.parentNode.insertBefore(btn, err.nextSibling);
|
||
}
|
||
btn.disabled = false;
|
||
btn.onclick = async function(){
|
||
btn.disabled = true;
|
||
let j = null;
|
||
try{
|
||
const r = await fetch('/auth/resend_verification.php', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json', 'X-CSRF-Token': window._csrfToken || ''},
|
||
body: JSON.stringify({user, pass})
|
||
});
|
||
j = await r.json();
|
||
}catch(_){}
|
||
if(j && j.ok){
|
||
showErr('signinErr', 'Verification email sent - check your inbox.');
|
||
btn.remove();
|
||
}else{
|
||
btn.disabled = false;
|
||
showErr('signinErr', (j && j.error) ? j.error : 'Could not send verification email.');
|
||
}
|
||
};
|
||
}
|
||
|
||
async function doLogin(){
|
||
showErr('signinErr', '');
|
||
setResend(false);
|
||
const user = (document.getElementById('signinUser')?.value || '').trim();
|
||
const pass = (document.getElementById('signinPass')?.value || '');
|
||
if(!user || !pass){
|
||
showErr('signinErr', 'Missing username/email or password.');
|
||
return;
|
||
}
|
||
|
||
const ok = await authLogin({ // lib/auth_ui.js
|
||
endpoint: '/auth/login.php', body: {user, pass},
|
||
onFail: j => {
|
||
showErr('signinErr', (j && j.error) ? j.error : 'Login failed.');
|
||
if(j && j.code === 'unverified') setResend(true, user, pass);
|
||
},
|
||
});
|
||
if(!ok) return;
|
||
|
||
setTimeout(function() { window.location.reload(); }, 100);
|
||
}
|
||
|
||
/* ---- Notification Bell (SSE) ---- */
|
||
|
||
let bellEvtSource = null;
|
||
|
||
function setupBell(isAdmin) {
|
||
const container = document.querySelector('.container-fluid');
|
||
if (!container) return;
|
||
|
||
// Insert bell before the user dropdown
|
||
const userDropdown = container.querySelector('.dropdown');
|
||
if (!userDropdown) return;
|
||
|
||
const bellWrap = document.createElement('div');
|
||
bellWrap.id = 'notifBellWrap';
|
||
bellWrap.style.cssText = 'position:relative; display:inline-block; margin-right:10px;';
|
||
bellWrap.innerHTML = ''
|
||
+ '<button id="notifBellBtn" style="background:none; border:none; color:var(--fg); cursor:pointer; font-size:1.2rem; position:relative; padding:4px 6px;" title="Notifications">'
|
||
+ '<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/></svg>'
|
||
+ '<span id="notifBadge" style="display:none; position:absolute; top:0; right:0; background:#ff4040; color:#fff; border-radius:50%; font-size:.65rem; min-width:16px; height:16px; line-height:16px; text-align:center; font-weight:bold;">0</span>'
|
||
+ '</button>'
|
||
+ '<div id="notifDropdown" style="display:none; position:absolute; right:0; top:100%; min-width:280px; max-width:360px; background:#111; border:1px solid rgba(0,255,255,.35); border-radius:8px; padding:8px 0; z-index:1050; max-height:400px; overflow-y:auto;">'
|
||
+ '<div style="display:flex; justify-content:space-between; align-items:center; padding:0 12px 6px; border-bottom:1px solid rgba(0,255,255,.2); margin-bottom:6px;">'
|
||
+ '<span style="color:rgba(0,255,255,.6); font-weight:600; font-size:.8rem;">Notifications</span>'
|
||
+ '<span style="display:flex; gap:4px;">'
|
||
+ '<button id="notifFilterBtn" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:1px 6px;" title="Show/hide categories">Filter</button>'
|
||
+ '<button id="notifClearBtn" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:1px 6px;" title="Dismiss everything shown">Clear all</button>'
|
||
+ '</span>'
|
||
+ '</div>'
|
||
+ '<div id="notifFilterPane" style="display:none; padding:2px 12px 6px; margin-bottom:6px; border-bottom:1px solid rgba(0,255,255,.1);"></div>'
|
||
+ '<div id="notifList" class="small" style="padding:0 12px;"></div>'
|
||
+ '</div>';
|
||
|
||
userDropdown.parentNode.insertBefore(bellWrap, userDropdown);
|
||
|
||
// Toggle dropdown
|
||
document.getElementById('notifBellBtn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const dd = document.getElementById('notifDropdown');
|
||
dd.style.display = dd.style.display === 'block' ? 'none' : 'block';
|
||
});
|
||
// Clicks inside the panel must not close it (Filter/Clear/dismiss live here).
|
||
document.getElementById('notifDropdown').addEventListener('click', (e) => e.stopPropagation());
|
||
document.getElementById('notifFilterBtn').addEventListener('click', () => {
|
||
const p = document.getElementById('notifFilterPane');
|
||
if (p) p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
||
});
|
||
document.getElementById('notifClearBtn').addEventListener('click', () => {
|
||
// Clear all = dismiss every currently-visible item until its state changes.
|
||
for (const k of currentBellKeys()) notifDismissAdd(k);
|
||
renderBell();
|
||
});
|
||
|
||
// Close on outside click
|
||
document.addEventListener('click', () => {
|
||
const dd = document.getElementById('notifDropdown');
|
||
if (dd) dd.style.display = 'none';
|
||
});
|
||
|
||
if (isAdmin) {
|
||
// Admins get SSE for pending CSRs + new registrations
|
||
startSSE();
|
||
}
|
||
// All users: load their own events
|
||
loadUserEvents();
|
||
}
|
||
|
||
// ── Bell state + filter/dismiss (V5-14K: DMR-style controls) ────────────
|
||
const BELL_CATS = [
|
||
{key:'csr', label:'Pending CSRs'},
|
||
{key:'reg', label:'New registrations'},
|
||
{key:'certs', label:'Certificate expiry'},
|
||
{key:'tgreq', label:'TG requests'},
|
||
{key:'events', label:'Account events'},
|
||
];
|
||
function bEsc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||
let bellData = {pending:[], new_accounts:[], ca_certs:[], tg_requests:[]};
|
||
let bellEvents = [];
|
||
function notifFilterGet() {
|
||
try { const r = localStorage.getItem('svxNotifFilter'); const a = r ? JSON.parse(r) : null;
|
||
return Array.isArray(a) ? new Set(a) : new Set(BELL_CATS.map(c => c.key)); }
|
||
catch(_) { return new Set(BELL_CATS.map(c => c.key)); }
|
||
}
|
||
function notifFilterSet(set) { try { localStorage.setItem('svxNotifFilter', JSON.stringify([...set])); } catch(_) {} }
|
||
function notifDismissedGet() {
|
||
try { const r = localStorage.getItem('svxNotifDismissed'); const a = r ? JSON.parse(r) : []; return new Set(Array.isArray(a) ? a : []); }
|
||
catch(_) { return new Set(); }
|
||
}
|
||
function notifDismissAdd(k) { const s = notifDismissedGet(); s.add(k); try { localStorage.setItem('svxNotifDismissed', JSON.stringify([...s].slice(-500))); } catch(_) {} }
|
||
// Keys of every item currently visible (filter-on, not dismissed) — Clear all.
|
||
function currentBellKeys() {
|
||
const en = notifFilterGet(), dis = notifDismissedGet(), keys = [];
|
||
const add = (cat, k) => { if (en.has(cat) && !dis.has(k)) keys.push(k); };
|
||
if (en.has('csr')) for (const p of (bellData.pending || [])) add('csr', 'csr:' + p.callsign);
|
||
if (en.has('reg')) for (const a of (bellData.new_accounts || [])) add('reg', 'reg:' + (a.uuid || a.callsign));
|
||
if (en.has('certs')) for (const c of (bellData.ca_certs || [])) add('certs', 'cert:' + c.name);
|
||
if (en.has('tgreq')) for (const p of (bellData.tg_requests || [])) add('tgreq', 'tgreq:' + p.uuid);
|
||
if (en.has('events')) for (const e of (bellEvents || [])) add('events', 'evt:' + e.ts);
|
||
return keys;
|
||
}
|
||
|
||
async function loadUserEvents() {
|
||
try {
|
||
const r = await fetch('/auth/user_events.php', {credentials:'same-origin'});
|
||
const j = await r.json();
|
||
bellEvents = (j && j.ok && Array.isArray(j.events)) ? j.events : [];
|
||
} catch(_) { bellEvents = []; }
|
||
renderBell();
|
||
}
|
||
|
||
function startSSE() {
|
||
if (bellEvtSource) bellEvtSource.close();
|
||
bellEvtSource = new EventSource('/auth/events.php');
|
||
bellEvtSource.addEventListener('notifications', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
updateBell(d);
|
||
} catch(_) {}
|
||
});
|
||
// Legacy support for old 'pending' event
|
||
bellEvtSource.addEventListener('pending', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
updateBell({pending_count: d.count, pending: d.items, new_accounts_count: 0, new_accounts: [], total: d.count});
|
||
} catch(_) {}
|
||
});
|
||
bellEvtSource.onerror = () => {
|
||
bellEvtSource.close();
|
||
setTimeout(startSSE, 10000);
|
||
};
|
||
}
|
||
|
||
// SSE pushes new data → store it and re-render (filter/dismiss applied there).
|
||
function updateBell(data) {
|
||
bellData = {
|
||
pending: data.pending || [],
|
||
new_accounts: data.new_accounts || [],
|
||
ca_certs: data.ca_certs || [],
|
||
tg_requests: data.tg_requests || [],
|
||
};
|
||
renderBell();
|
||
}
|
||
|
||
// Single render path for the whole bell — applies the category filter and the
|
||
// per-item dismiss set, wires all actions. Mirrors the dmr bell's Filter +
|
||
// Clear all + per-row dismiss.
|
||
function renderBell() {
|
||
const badge = document.getElementById('notifBadge');
|
||
const list = document.getElementById('notifList');
|
||
const pane = document.getElementById('notifFilterPane');
|
||
if (!list) return;
|
||
const en = notifFilterGet();
|
||
const dis = notifDismissedGet();
|
||
|
||
// Filter pane checkboxes
|
||
if (pane) {
|
||
pane.innerHTML = '';
|
||
for (const c of BELL_CATS) {
|
||
const wrap = document.createElement('label');
|
||
wrap.style.cssText = 'display:inline-flex; align-items:center; gap:4px; margin-right:10px; font-size:.75rem; color:rgba(0,255,255,.7); cursor:pointer;';
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox'; cb.checked = en.has(c.key);
|
||
cb.addEventListener('change', () => {
|
||
const s = notifFilterGet();
|
||
if (cb.checked) s.add(c.key); else s.delete(c.key);
|
||
notifFilterSet(s); renderBell();
|
||
});
|
||
wrap.appendChild(cb);
|
||
wrap.appendChild(document.createTextNode(c.label));
|
||
pane.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
const hdr = (t) => '<div style="padding:4px 0; color:rgba(0,255,255,.6); font-weight:600; border-bottom:1px solid rgba(0,255,255,.2); margin:8px 0 4px;">' + t + '</div>';
|
||
const dz = (k) => '<button class="notif-dismiss" data-key="' + k + '" title="Dismiss" style="background:none; border:none; color:rgba(0,255,255,.5); cursor:pointer; font-size:1rem; line-height:1; padding:0 2px;">×</button>';
|
||
let html = '', visible = 0;
|
||
|
||
// Pending CSRs
|
||
if (en.has('csr')) {
|
||
const items = (bellData.pending || []).filter(p => !dis.has('csr:' + p.callsign));
|
||
if (items.length) {
|
||
html += hdr('Pending CSRs');
|
||
for (const item of items) {
|
||
const key = 'csr:' + item.callsign; visible++;
|
||
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px;">'
|
||
+ '<div><strong style="font-family:monospace;">' + item.callsign + '</strong><br><span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(item.mtime) + '</span></div>'
|
||
+ '<div style="display:flex; gap:4px; align-items:center;">'
|
||
+ '<button class="btn btn-success btn-sm notif-sign" data-cs="' + item.callsign + '" style="font-size:.7rem; padding:2px 8px;">Sign</button>'
|
||
+ '<button class="btn btn-danger btn-sm notif-reject" data-cs="' + item.callsign + '" style="font-size:.7rem; padding:2px 8px;">Reject</button>'
|
||
+ dz(key) + '</div></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// New registrations
|
||
if (en.has('reg')) {
|
||
const items = (bellData.new_accounts || []).filter(a => !dis.has('reg:' + (a.uuid || a.callsign)));
|
||
if (items.length) {
|
||
html += hdr('New Registrations');
|
||
for (const a of items) {
|
||
const key = 'reg:' + (a.uuid || a.callsign); visible++;
|
||
const status = !a.verified ? 'Awaiting email verification' : (a.approved ? 'Approved' : 'Pending approval — cannot log in');
|
||
const statusColor = !a.verified ? '#ffc800' : (a.approved ? '#00c864' : '#ff9800');
|
||
const canApprove = a.verified && !a.approved && a.uuid;
|
||
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap;">'
|
||
+ '<div style="flex:1 1 auto;"><strong style="font-family:monospace;">' + a.callsign + '</strong> <span style="color:rgba(0,255,255,.5);">' + a.firstname + '</span>'
|
||
+ '<br><span style="color:' + statusColor + '; font-size:.75rem;">' + status + '</span>'
|
||
+ ' <span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(a.created) + '</span></div>'
|
||
+ '<div style="display:flex; gap:4px; align-items:center;">'
|
||
+ (canApprove ? '<button class="btn btn-success btn-sm notif-approve" data-uuid="' + a.uuid + '" data-cs="' + a.callsign + '" data-flags="' + (a.flags || 0) + '" style="font-size:.7rem; padding:2px 8px;">Approve</button>' : '')
|
||
+ '<a href="/auth/admin.php" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:2px 8px;">Review</a>'
|
||
+ dz(key) + '</div>'
|
||
+ '<div class="notif-approve-err" style="flex:1 1 100%; color:#ff8080; font-size:.7rem;"></div>'
|
||
+ '</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Certificate expiry
|
||
if (en.has('certs')) {
|
||
const items = (bellData.ca_certs || []).filter(c => !dis.has('cert:' + c.name));
|
||
if (items.length) {
|
||
html += hdr('Certificate expiry');
|
||
for (const c of items) {
|
||
const key = 'cert:' + c.name; visible++;
|
||
const color = c.expired ? '#ff4040' : '#ffc800';
|
||
const label = c.expired ? 'EXPIRED' : (c.days_left + ' day' + (c.days_left === 1 ? '' : 's') + ' left');
|
||
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px;">'
|
||
+ '<div><strong style="font-family:monospace;">' + c.name + '</strong><br><span style="color:' + color + '; font-size:.8rem;">' + label + '</span></div>'
|
||
+ dz(key) + '</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// TG requests (awaiting admin approval)
|
||
if (en.has('tgreq')) {
|
||
const items = (bellData.tg_requests || []).filter(p => !dis.has('tgreq:' + p.uuid));
|
||
if (items.length) {
|
||
html += hdr('TG Requests');
|
||
for (const p of items) {
|
||
const key = 'tgreq:' + p.uuid; visible++;
|
||
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap;">'
|
||
+ '<div style="flex:1 1 auto;"><strong style="font-family:monospace;">TG ' + bEsc(p.tg) + '</strong> <span style="color:rgba(0,255,255,.5);">' + bEsc(p.name) + '</span>'
|
||
+ '<br><span style="font-family:monospace; color:rgba(0,255,255,.6); font-size:.75rem;">' + bEsc(p.callsign) + '</span>'
|
||
+ ' <span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(p.ts) + '</span></div>'
|
||
+ '<div style="display:flex; gap:4px; align-items:center;">'
|
||
+ '<button class="btn btn-success btn-sm notif-tgapprove" data-uuid="' + bEsc(p.uuid) + '" style="font-size:.7rem; padding:2px 8px;">Approve</button>'
|
||
+ '<button class="btn btn-danger btn-sm notif-tgdeny" data-uuid="' + bEsc(p.uuid) + '" style="font-size:.7rem; padding:2px 8px;">Deny</button>'
|
||
+ '<a href="/auth/tgadmin.php" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:2px 8px;">Open</a>'
|
||
+ dz(key) + '</div></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Account events
|
||
if (en.has('events')) {
|
||
const items = (bellEvents || []).filter(e => !dis.has('evt:' + e.ts));
|
||
if (items.length) {
|
||
html += hdr('Account Events');
|
||
for (const evt of items.slice(0, 10)) {
|
||
const key = 'evt:' + evt.ts; visible++;
|
||
let label = evt.type || '?', color = '#00ffff';
|
||
if (evt.type === 'ban') { label = 'Account banned'; color = '#ff4040'; }
|
||
else if (evt.type === 'unban') { label = 'Account unbanned'; color = '#00c864'; }
|
||
else if (evt.type === 'lock') { label = 'Account locked'; color = '#ffc800'; }
|
||
else if (evt.type === 'unlock') { label = 'Account unlocked'; color = '#00c864'; }
|
||
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:flex-start; gap:6px;">'
|
||
+ '<div><strong style="color:' + color + ';">' + label + '</strong>'
|
||
+ (evt.reason ? '<br><span style="color:rgba(0,255,255,.5); font-size:.8rem;">Reason: ' + evt.reason + '</span>' : '')
|
||
+ '<br><span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(evt.ts) + (evt.by ? ' by ' + evt.by : '') + '</span></div>'
|
||
+ dz(key) + '</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
list.innerHTML = html || '<div style="padding:8px 0; color:rgba(0,255,255,.5);">No notifications</div>';
|
||
|
||
if (badge) {
|
||
if (visible > 0) { badge.textContent = String(visible); badge.style.display = ''; }
|
||
else badge.style.display = 'none';
|
||
}
|
||
|
||
// Dismiss (× per row) — hides the item until its state changes.
|
||
list.querySelectorAll('.notif-dismiss').forEach(btn => {
|
||
btn.addEventListener('click', (e) => { e.stopPropagation(); notifDismissAdd(btn.dataset.key); renderBell(); });
|
||
});
|
||
// TG request approve / deny (SSE re-pushes on the next tick; dismiss now for snappiness).
|
||
const tgReq = async (action, uuid) => {
|
||
const 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({action, uuid}) });
|
||
return r.json().catch(() => null);
|
||
};
|
||
list.querySelectorAll('.notif-tgapprove').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation(); btn.disabled = true;
|
||
const j = await tgReq('approve', btn.dataset.uuid);
|
||
if (j && j.ok) { notifDismissAdd('tgreq:' + btn.dataset.uuid); renderBell(); }
|
||
else { alert((j && j.error) || 'Failed'); btn.disabled = false; }
|
||
});
|
||
});
|
||
list.querySelectorAll('.notif-tgdeny').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if (!confirm('Deny this TG request?')) return;
|
||
btn.disabled = true;
|
||
const j = await tgReq('deny', btn.dataset.uuid);
|
||
if (j && j.ok) { notifDismissAdd('tgreq:' + btn.dataset.uuid); renderBell(); }
|
||
else { alert((j && j.error) || 'Failed'); btn.disabled = false; }
|
||
});
|
||
});
|
||
// Sign CSR
|
||
list.querySelectorAll('.notif-sign').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if (!confirm('Sign certificate for ' + btn.dataset.cs + '?')) return;
|
||
btn.disabled = true;
|
||
const 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({action:'sign', callsign: btn.dataset.cs})
|
||
});
|
||
const j = await r.json().catch(() => null);
|
||
if (!j || !j.ok) alert((j && j.error) || 'Failed');
|
||
});
|
||
});
|
||
// Reject CSR
|
||
list.querySelectorAll('.notif-reject').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if (!confirm('Reject CSR for ' + btn.dataset.cs + '?')) return;
|
||
btn.disabled = true;
|
||
const 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({action:'rmpending', callsign: btn.dataset.cs})
|
||
});
|
||
const j = await r.json().catch(() => null);
|
||
if (!j || !j.ok) alert((j && j.error) || 'Failed');
|
||
});
|
||
});
|
||
// Approve a new registration inline — set reg(2)+normal(4) so the user can
|
||
// log in. Errors surface next to the row.
|
||
list.querySelectorAll('.notif-approve').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const row = btn.closest('.notif-row');
|
||
const errEl = row ? row.querySelector('.notif-approve-err') : null;
|
||
btn.disabled = true; btn.textContent = '…';
|
||
const flags = ((parseInt(btn.dataset.flags, 10) || 0) | 2 | 4) & 0x3F;
|
||
const r = await fetch('/auth/admin_accounts.php', {
|
||
method:'POST', credentials:'same-origin',
|
||
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
|
||
body: JSON.stringify({action:'set_flags', uuid: btn.dataset.uuid, flags})
|
||
});
|
||
const j = await r.json().catch(() => null);
|
||
if (j && j.ok) { if (row) row.remove(); }
|
||
else {
|
||
btn.disabled = false; btn.textContent = 'Approve';
|
||
if (errEl) errEl.textContent = 'Approve failed: ' + ((j && j.error) || 'Failed');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function timeAgo(epoch) {
|
||
const diff = Math.floor(Date.now() / 1000) - epoch;
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||
return Math.floor(diff / 86400) + 'd ago';
|
||
}
|
||
|
||
/* ---- Session check & navbar ---- */
|
||
|
||
function showForgot() {
|
||
const el = document.getElementById('forgotModal');
|
||
if (!el || !window.bootstrap) return;
|
||
const m = new bootstrap.Modal(el);
|
||
m.show();
|
||
}
|
||
|
||
async function doForgot(e) {
|
||
if (e) e.preventDefault();
|
||
const msg = document.getElementById('forgotMsg');
|
||
if (msg) { msg.style.display = 'none'; }
|
||
const callsign = (document.getElementById('forgotCallsign')?.value || '').trim().toUpperCase();
|
||
const email = (document.getElementById('forgotEmail')?.value || '').trim();
|
||
if (!callsign || !email) {
|
||
if (msg) { msg.textContent = 'Fill in both fields.'; msg.style.color = '#ff8080'; msg.style.display = 'block'; }
|
||
return;
|
||
}
|
||
try {
|
||
const r = await fetch('/auth/forgot_password.php', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({callsign, email})
|
||
});
|
||
const j = await r.json();
|
||
if (msg) {
|
||
msg.textContent = (j && j.message) || 'Check your email for the reset link.';
|
||
msg.style.color = '#00ff80';
|
||
msg.style.display = 'block';
|
||
}
|
||
} catch(_) {
|
||
if (msg) { msg.textContent = 'Request failed.'; msg.style.color = '#ff8080'; msg.style.display = 'block'; }
|
||
}
|
||
}
|
||
|
||
function showRegister() {
|
||
const el = document.getElementById('registerModal');
|
||
if (!el || !window.bootstrap) return;
|
||
const m = new bootstrap.Modal(el);
|
||
m.show();
|
||
}
|
||
|
||
function setRegMsg(txt, err) {
|
||
const el = document.getElementById('regMsg');
|
||
if (!el) return;
|
||
if (!txt) { el.style.display = 'none'; return; }
|
||
el.textContent = txt;
|
||
el.style.color = err ? '#ff8080' : '#00ff80';
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
async function doRegister(e) { // lib/auth_ui.js
|
||
if (e) e.preventDefault();
|
||
await authRegister({ endpoint: '/auth/signup.php', msg: setRegMsg });
|
||
}
|
||
|
||
async function checkSession(){
|
||
try{
|
||
const r = await fetch('/auth/me.php', {credentials:'same-origin'});
|
||
const j = await r.json();
|
||
if (!j || !j.logged_in) return;
|
||
|
||
const btn = document.getElementById('btnSignIn');
|
||
if (!btn) return;
|
||
const label = (j.callsign || j.email || 'User');
|
||
window._csrfToken = j.csrf_token || '';
|
||
// Theme on login: the account (uuid/redis) profile is authoritative. When
|
||
// signed in, ALWAYS apply the saved account theme + colours — that is the
|
||
// "real" pick and it must win over whatever the pre-paint boot read from the
|
||
// local cookie (you may have set a different one while logged out). A change
|
||
// made later in the session mirrors back to the account (theme.js
|
||
// saveToServer); the _adopted guard keeps a second load from re-adopting over
|
||
// it. An account that never saved a theme has empty web_theme/web_theme_colors
|
||
// → adopt skipped, the cookie/dark boot stands.
|
||
if (window.svxTheme && !window.svxTheme._adopted &&
|
||
(j.web_theme || j.web_theme_colors)) {
|
||
window.svxTheme._adopted = true;
|
||
window.svxTheme.adoptServer(j.web_theme || '', j.web_theme_colors || '');
|
||
}
|
||
const isAdmin = j.admin_level >= 2;
|
||
window._isAdmin = isAdmin;
|
||
const adminBlock = isAdmin
|
||
? '<li id="adminMenuItem" style="position:relative;">'
|
||
+ '<a class="dropdown-item" href="#" id="adminSubToggle">Admin ▸</a>'
|
||
+ '<ul id="adminSubMenu" style="display:none;">'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin.php">Accounts</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_control.php">Control</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_mqtt.php">MQTT</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_certs.php">Certificates</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_nodeauth.php">Node Auth</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_bans.php">Global Bans</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/tgadmin.php">TG List (admin)</a></li>'
|
||
+ (j.admin_level >= 3 ? '<li><a class="dropdown-item" href="/auth/admin_config.php">Config</a></li>' : '')
|
||
+ '</ul>'
|
||
+ '</li>'
|
||
+ '<li><hr class="dropdown-divider"></li>'
|
||
: '';
|
||
const html = ''
|
||
+ '<div class="dropdown">'
|
||
+ '<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="userMenuBtn" data-bs-toggle="dropdown" aria-expanded="false">'
|
||
+ label
|
||
+ '</button>'
|
||
+ '<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuBtn">'
|
||
+ '<li><a class="dropdown-item" href="/auth/profile.php">Profile</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/admin_tglist.php">TG List</a></li>'
|
||
+ '<li><a class="dropdown-item" href="/auth/tgrequest.php">Request TG</a></li>'
|
||
+ adminBlock
|
||
+ '<li><a class="dropdown-item" href="#" id="menuSignOut">Sign out</a></li>'
|
||
+ '</ul>'
|
||
+ '</div>';
|
||
btn.outerHTML = html;
|
||
// Remove Register button when logged in
|
||
const regBtn = document.getElementById('btnRegister');
|
||
if (regBtn) regBtn.remove();
|
||
|
||
// Sign out
|
||
document.getElementById('menuSignOut')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
doLogout();
|
||
});
|
||
|
||
// Admin submenu toggle (click)
|
||
document.getElementById('adminSubToggle')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const m = document.getElementById('adminSubMenu');
|
||
if (m) m.style.display = m.style.display === 'block' ? 'none' : 'block';
|
||
});
|
||
// Hover open
|
||
document.getElementById('adminMenuItem')?.addEventListener('mouseenter', () => {
|
||
const m = document.getElementById('adminSubMenu');
|
||
if (m) m.style.display = 'block';
|
||
});
|
||
document.getElementById('adminMenuItem')?.addEventListener('mouseleave', () => {
|
||
const m = document.getElementById('adminSubMenu');
|
||
if (m) m.style.display = 'none';
|
||
});
|
||
|
||
// Setup notification bell for all users
|
||
setupBell(isAdmin);
|
||
} catch(_) {}
|
||
}
|
||
|
||
async function doLogout(){ // lib/auth_ui.js
|
||
await authLogout({ endpoint: '/auth/logout.php', method: 'POST', after: () => {
|
||
if (bellEvtSource) bellEvtSource.close();
|
||
window.location.reload();
|
||
} });
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
showCookieModalIfNeeded();
|
||
checkSession();
|
||
|
||
document.getElementById('btnRegister')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showRegister();
|
||
});
|
||
|
||
document.getElementById('btnSignIn')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showSignin();
|
||
});
|
||
|
||
document.getElementById('linkSignup')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
// Close signin modal if open
|
||
const sm = document.getElementById('signinModal');
|
||
if (sm) { const inst = bootstrap.Modal.getInstance(sm); if (inst) inst.hide(); }
|
||
showRegister();
|
||
});
|
||
|
||
document.getElementById('linkForgot')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const sm = document.getElementById('signinModal');
|
||
if (sm) { const inst = bootstrap.Modal.getInstance(sm); if (inst) inst.hide(); }
|
||
showForgot();
|
||
});
|
||
|
||
document.getElementById('forgotForm')?.addEventListener('submit', doForgot);
|
||
|
||
document.getElementById('registerForm')?.addEventListener('submit', doRegister);
|
||
|
||
document.getElementById('signinForm')?.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
doLogin();
|
||
});
|
||
});
|
||
})();
|