Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md

Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr),
deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles
live in install/ with CHANGEME placeholders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:07:05 +02:00
co-authored by Claude Fable 5.1
commit 25b481e2ae
93 changed files with 13357 additions and 0 deletions
+664
View File
@@ -0,0 +1,664 @@
'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();
});
});
})();