'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 = '' + '' + ''; 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) => '
' + t + '
'; const dz = (k) => ''; 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 += '
' + '
' + item.callsign + '
' + timeAgo(item.mtime) + '
' + '
' + '' + '' + dz(key) + '
'; } } } // 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 += '
' + '
' + a.callsign + ' ' + a.firstname + '' + '
' + status + '' + ' ' + timeAgo(a.created) + '
' + '
' + (canApprove ? '' : '') + 'Review' + dz(key) + '
' + '
' + '
'; } } } // 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 += '
' + '
' + c.name + '
' + label + '
' + dz(key) + '
'; } } } // 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 += '
' + '
TG ' + bEsc(p.tg) + ' ' + bEsc(p.name) + '' + '
' + bEsc(p.callsign) + '' + ' ' + timeAgo(p.ts) + '
' + '
' + '' + '' + 'Open' + dz(key) + '
'; } } } // 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 += '
' + '
' + label + '' + (evt.reason ? '
Reason: ' + evt.reason + '' : '') + '
' + timeAgo(evt.ts) + (evt.by ? ' by ' + evt.by : '') + '
' + dz(key) + '
'; } } } list.innerHTML = html || '
No notifications
'; 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 ? '
  • ' + 'Admin ▸' + '' + '
  • ' + '
  • ' : ''; const html = '' + ''; 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(); }); }); })();