Files

674 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;">&times;</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 &#9656;</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();
});
});
})();