// SvxPortal — 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 = ''
+ ''
+ '
'
+ '
'
+ 'Notifications'
+ ''
+ ''
+ ''
+ ''
+ '
'
+ '
'
+ '
'
+ '
';
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
? ''
+ '
'
: '';
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();
});
});
})();