'use strict'; // ── State ────────────────────────────────────────────────────────────────── let currentUser = null; let editingUserId = null; let users = []; let companyFilter = ''; let roleFilter = new Set(); // empty = all roles let statusFilter = null; // null | 'active' | 'inactive' let groupBy = 'company'; // 'company' | 'role' | 'status' | null let syncroCustomers = []; // [{ id, name }] loaded once from Syncro // ── Init ─────────────────────────────────────────────────────────────────── (async function init() { try { const r = await fetch('/auth/me'); if (!r.ok) { location.replace('/login.html?next=' + encodeURIComponent(location.pathname)); return; } const { user } = await r.json(); currentUser = user; document.getElementById('user-name').textContent = user.name; const roleBadge = document.getElementById('user-role'); roleBadge.textContent = user.role; roleBadge.dataset.role = user.role; // Show server section only for superduperadmin if (user.role === 'superduperadmin') { document.getElementById('section-server').style.display = ''; } // Hide elevated role options for non-superduperadmin if (user.role !== 'superduperadmin') { document.querySelector('#f-role option[value="superduperadmin"]').remove(); document.querySelector('#f-role option[value="admin"]').remove(); } // Load users and Syncro customers in parallel await Promise.all([loadUsers(), loadSyncroCustomers()]); bindEvents(); loadApiUsage(); setInterval(loadApiUsage, 10_000); } catch (e) { console.error(e); } })(); // ── API helpers ──────────────────────────────────────────────────────────── async function api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const r = await fetch(path, opts); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); return data; } // ── Syncro customers ─────────────────────────────────────────────────────── async function loadSyncroCustomers() { const hint = document.getElementById('f-customer-hint'); const listbox = document.getElementById('f-syncro-customer'); try { let page = 1, all = []; while (true) { const r = await fetch(`/syncro-api/customers?per_page=100&page=${page}`); const data = await r.json(); const batch = data.customers ?? []; const totalPages = data.meta?.total_pages ?? 1; all = all.concat(batch); if (page >= totalPages) break; page++; } syncroCustomers = all .map(c => ({ id: c.id, name: c.business_name ?? c.name ?? `Customer ${c.id}` })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); populateCustomerListbox(''); hint.textContent = 'Links this user to a Syncro customer for access control.'; } catch (e) { hint.textContent = 'Could not load Syncro customers.'; console.error('Syncro customer load failed:', e); } } function populateCustomerListbox(filter) { const listbox = document.getElementById('f-syncro-customer'); const current = listbox.value; const q = filter.toLowerCase(); // Keep the "None" option then add matching customers const matches = q ? syncroCustomers.filter(c => c.name.toLowerCase().includes(q)) : syncroCustomers; listbox.innerHTML = ''; for (const c of matches) { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.name; listbox.appendChild(opt); } // Restore selection if still present if (current && listbox.querySelector(`option[value="${current}"]`)) { listbox.value = current; } } // ── Load & render users ──────────────────────────────────────────────────── async function loadUsers() { try { const { users: list } = await api('GET', '/admin/users'); users = list; rebuildCompanyFilter(); renderUsers(); } catch (e) { document.getElementById('user-tbody').innerHTML = `Failed to load users: ${e.message}`; } } function rebuildCompanyFilter() { // Collect unique non-empty companies, sorted const companies = [...new Set( users.map(u => u.company).filter(Boolean) )].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); // Filter dropdown const select = document.getElementById('company-filter'); const prev = select.value; select.innerHTML = ''; for (const c of companies) { const opt = document.createElement('option'); opt.value = c; opt.textContent = c; select.appendChild(opt); } select.value = companies.includes(prev) ? prev : ''; companyFilter = select.value; } function renderUsers() { const tbody = document.getElementById('user-tbody'); const visible = users.filter(u => { if (companyFilter && u.company !== companyFilter) return false; if (roleFilter.size > 0 && !roleFilter.has(u.role)) return false; if (statusFilter === 'active' && !u.active) return false; if (statusFilter === 'inactive' && u.active) return false; return true; }); const hasFilter = companyFilter || roleFilter.size > 0 || statusFilter !== null; if (!visible.length) { tbody.innerHTML = `${hasFilter ? 'No users match the current filters.' : 'No users found.'}`; return; } const rows = []; if (groupBy) { const keyOf = u => { if (groupBy === 'company') return u.company || ''; if (groupBy === 'role') return u.role; if (groupBy === 'status') return u.active ? 'active' : 'inactive'; return ''; }; const labelOf = key => { if (groupBy === 'company') return key || 'No Company'; if (groupBy === 'role') return { client: 'Client', tech: 'Tech', admin: 'Admin', superduperadmin: 'Super Admin' }[key] ?? key; if (groupBy === 'status') return key === 'active' ? 'Active' : 'Inactive'; return key; }; const sortKeys = keys => { if (groupBy === 'company') return keys.sort((a, b) => !a && b ? 1 : a && !b ? -1 : a.localeCompare(b, undefined, { sensitivity: 'base' })); if (groupBy === 'role') return keys.sort((a, b) => (['superduperadmin','admin','tech','client'].indexOf(a) - ['superduperadmin','admin','tech','client'].indexOf(b))); if (groupBy === 'status') return keys.sort((a, b) => a === 'active' ? -1 : 1); return keys; }; const grouped = new Map(); for (const u of visible) { const key = keyOf(u); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(u); } for (const key of sortKeys([...grouped.keys()])) { rows.push(`${esc(labelOf(key))}`); for (const u of grouped.get(key)) rows.push(userRow(u)); } } else { for (const u of visible) rows.push(userRow(u)); } tbody.innerHTML = rows.join(''); } function userRow(u) { const activeClass = u.active ? 'active' : 'inactive'; const activeLabel = u.active ? 'Active' : 'Inactive'; const created = new Date(u.created_at + 'Z').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); const isSelf = u.id === currentUser.id; const toggleBtn = u.active ? `` : ``; return ` ${esc(u.name)}${isSelf ? ' (you)' : ''} ${esc(u.username)} ${u.company ? esc(u.company) : ''} ${esc(u.role)} ${activeLabel} ${created} ${toggleBtn} `; } // ── App menu ─────────────────────────────────────────────────────────────── function initAdminMenu() { const toggle = document.getElementById('btn-menu-toggle'); const menu = document.getElementById('app-menu'); if (!toggle || !menu) return; toggle.addEventListener('click', e => { e.stopPropagation(); const opening = menu.hidden; menu.hidden = !opening; toggle.setAttribute('aria-expanded', String(opening)); }); document.addEventListener('click', e => { if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) { menu.hidden = true; toggle.setAttribute('aria-expanded', 'false'); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; toggle.setAttribute('aria-expanded', 'false'); } }); } // ── User filter panel ────────────────────────────────────────────────────── function bindFilterPanel() { const panel = document.getElementById('user-filter-panel'); const filterBtn = document.getElementById('btn-user-filter'); filterBtn?.addEventListener('click', () => { panel.hidden = !panel.hidden; filterBtn.classList.toggle('active', !panel.hidden); }); panel?.addEventListener('click', e => { const chip = e.target.closest('.af-chip'); if (!chip) return; const group = chip.closest('[data-filter-group]')?.dataset.filterGroup; if (group === 'role') { const val = chip.dataset.value; if (roleFilter.has(val)) { roleFilter.delete(val); chip.classList.remove('active'); } else { roleFilter.add(val); chip.classList.add('active'); } } else if (group === 'status') { statusFilter = chip.dataset.value === '' ? null : chip.dataset.value; chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c => c.classList.toggle('active', c.dataset.value === (chip.dataset.value)) ); } else if (group === 'groupby') { groupBy = chip.dataset.value === '' ? null : chip.dataset.value; chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c => c.classList.toggle('active', c.dataset.value === chip.dataset.value) ); } updateFilterBadge(); renderUsers(); }); document.getElementById('af-clear')?.addEventListener('click', () => { roleFilter.clear(); statusFilter = null; companyFilter = ''; document.getElementById('company-filter').value = ''; panel?.querySelectorAll('[data-filter-group="role"] .af-chip').forEach(c => c.classList.remove('active')); panel?.querySelectorAll('[data-filter-group="status"] .af-chip').forEach(c => c.classList.toggle('active', c.dataset.value === '') ); groupBy = 'company'; panel?.querySelectorAll('[data-filter-group="groupby"] .af-chip').forEach(c => c.classList.toggle('active', c.dataset.value === 'company') ); updateFilterBadge(); renderUsers(); }); document.getElementById('company-filter')?.addEventListener('change', e => { companyFilter = e.target.value; updateFilterBadge(); renderUsers(); }); } function updateFilterBadge() { const count = (companyFilter ? 1 : 0) + roleFilter.size + (statusFilter !== null ? 1 : 0); const badge = document.getElementById('user-filter-badge'); if (badge) { badge.textContent = count; badge.hidden = count === 0; } } // ── Bind events ──────────────────────────────────────────────────────────── function bindEvents() { initAdminMenu(); bindFilterPanel(); document.getElementById('btn-new-user').addEventListener('click', openCreate); document.getElementById('btn-logout').addEventListener('click', async () => { await fetch('/auth/logout', { method: 'POST' }); location.replace('/login.html'); }); // Table actions — delegated so they work after innerHTML re-renders document.getElementById('user-tbody').addEventListener('click', e => { const btn = e.target.closest('button[data-action]'); if (!btn || btn.disabled) return; const id = Number(btn.dataset.id); if (btn.dataset.action === 'edit') { openEdit(id); } else if (btn.dataset.action === 'toggle') { confirmToggleActive(id, btn.dataset.activate === 'true'); } }); // Customer picker search document.getElementById('f-customer-search').addEventListener('input', e => { populateCustomerListbox(e.target.value); }); // Modal close / cancel document.getElementById('modal-close').addEventListener('click', closeUserModal); document.getElementById('modal-cancel').addEventListener('click', closeUserModal); document.getElementById('user-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeUserModal(); }); // Confirm modal document.getElementById('confirm-close').addEventListener('click', closeConfirm); document.getElementById('confirm-cancel').addEventListener('click', closeConfirm); document.getElementById('confirm-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeConfirm(); }); document.getElementById('user-form').addEventListener('submit', handleUserFormSubmit); const btnRestart = document.getElementById('btn-restart'); if (btnRestart) { btnRestart.addEventListener('click', () => { showConfirm( 'Restart Server', 'The server process will exit and PM2 will restart it automatically. This takes about 1–2 seconds. Proceed?', 'Restart', 'btn-accent', doRestart ); }); } document.getElementById('btn-refresh-usage')?.addEventListener('click', loadApiUsage); // History panel toggle document.getElementById('btn-history-toggle')?.addEventListener('click', () => { _historyPanelVisible = !_historyPanelVisible; const panel = document.getElementById('usage-history-panel'); if (panel) panel.hidden = !_historyPanelVisible; document.getElementById('btn-history-toggle')?.classList.toggle('active', _historyPanelVisible); _setHistoryWindowDescription(); loadApiUsage(); // immediate refresh so bar + chart update }); // Timeframe chips document.querySelectorAll('.usage-tf-chip').forEach(chip => { chip.addEventListener('click', () => { _historyWindowMs = parseInt(chip.dataset.window, 10); document.querySelectorAll('.usage-tf-chip').forEach(c => c.classList.toggle('active', c === chip) ); _setHistoryWindowDescription(); loadApiUsage(); }); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeUserModal(); closeConfirm(); } }); } // ── User modal ───────────────────────────────────────────────────────────── function openCreate() { editingUserId = null; document.getElementById('modal-title').textContent = 'New User'; document.getElementById('modal-submit').textContent = 'Create User'; document.getElementById('f-password-label').textContent = 'Password'; document.getElementById('f-password-hint').textContent = 'Minimum 12 characters.'; document.getElementById('f-password').required = true; document.getElementById('user-form').reset(); document.getElementById('modal-error').style.display = 'none'; document.getElementById('f-username').disabled = false; document.getElementById('f-role').disabled = false; document.getElementById('f-customer-search').value = ''; populateCustomerListbox(''); document.getElementById('f-syncro-customer').value = ''; document.getElementById('user-modal').style.display = 'flex'; document.getElementById('f-name').focus(); } function openEdit(id) { const u = users.find(x => x.id === id); if (!u) return; editingUserId = id; document.getElementById('modal-title').textContent = 'Edit User'; document.getElementById('modal-submit').textContent = 'Save Changes'; document.getElementById('f-password-label').textContent = 'New Password'; document.getElementById('f-password-hint').textContent = 'Leave blank to keep current password. Minimum 12 characters if changing.'; document.getElementById('f-password').required = false; document.getElementById('modal-error').style.display = 'none'; const isSelf = u.id === currentUser.id; document.getElementById('f-name').value = u.name; document.getElementById('f-username').value = u.username; document.getElementById('f-username').disabled = true; document.getElementById('f-role').disabled = isSelf; document.getElementById('f-password').value = ''; // Pre-select Syncro customer document.getElementById('f-customer-search').value = ''; populateCustomerListbox(''); document.getElementById('f-syncro-customer').value = u.syncro_customer_id ?? ''; const roleSelect = document.getElementById('f-role'); if (!roleSelect.querySelector(`option[value="${u.role}"]`)) { const opt = document.createElement('option'); opt.value = u.role; opt.textContent = u.role; opt.dataset.temp = '1'; roleSelect.insertBefore(opt, roleSelect.firstChild); } roleSelect.value = u.role; document.getElementById('user-modal').style.display = 'flex'; document.getElementById('f-name').focus(); } function closeUserModal() { document.getElementById('user-modal').style.display = 'none'; document.getElementById('f-role').disabled = false; document.querySelectorAll('#f-role option[data-temp]').forEach(o => o.remove()); } async function handleUserFormSubmit(e) { e.preventDefault(); const errEl = document.getElementById('modal-error'); const submit = document.getElementById('modal-submit'); errEl.style.display = 'none'; submit.disabled = true; const name = document.getElementById('f-name').value.trim(); const username = document.getElementById('f-username').value.trim(); const role = document.getElementById('f-role').value; const password = document.getElementById('f-password').value; const customerSelect = document.getElementById('f-syncro-customer'); const syncroCustomerId = customerSelect.value ? Number(customerSelect.value) : null; const company = syncroCustomerId ? (customerSelect.options[customerSelect.selectedIndex]?.text ?? '') : ''; try { if (editingUserId === null) { await api('POST', '/admin/users', { name, username, company, syncro_customer_id: syncroCustomerId, role, password }); toast('User created successfully.', 'success'); } else { const body = { name, company, syncro_customer_id: syncroCustomerId, role }; if (password) body.password = password; await api('PATCH', `/admin/users/${editingUserId}`, body); toast('User updated successfully.', 'success'); } closeUserModal(); await loadUsers(); } catch (err) { errEl.textContent = err.message; errEl.style.display = ''; } finally { submit.disabled = false; } } // ── Toggle active ────────────────────────────────────────────────────────── function confirmToggleActive(id, activate) { const u = users.find(x => x.id === id); if (!u) return; const action = activate ? 'activate' : 'deactivate'; showConfirm( `${activate ? 'Activate' : 'Deactivate'} User`, `Are you sure you want to ${action} ${esc(u.name)} (${esc(u.username)})?${!activate ? ' They will be unable to log in.' : ''}`, activate ? 'Activate' : 'Deactivate', activate ? 'btn-primary' : 'btn-danger', async () => { try { await api('PATCH', `/admin/users/${id}`, { active: activate }); toast(`User ${activate ? 'activated' : 'deactivated'}.`, activate ? 'success' : 'info'); await loadUsers(); } catch (err) { toast(err.message, 'error'); } } ); } // ── Restart ──────────────────────────────────────────────────────────────── async function doRestart() { const btn = document.getElementById('btn-restart'); btn.disabled = true; btn.innerHTML = ` Restarting…`; try { await api('POST', '/admin/restart'); } catch { // Server closed before responding — expected } toast('Server is restarting… page will refresh in a few seconds.', 'info'); setTimeout(() => location.reload(), 4000); } // ── Confirm modal ────────────────────────────────────────────────────────── function showConfirm(title, message, okLabel, okClass, callback) { document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-message').innerHTML = message; const okBtn = document.getElementById('confirm-ok'); okBtn.textContent = okLabel; okBtn.className = `btn btn-sm ${okClass}`; okBtn.onclick = async () => { closeConfirm(); await callback(); }; document.getElementById('confirm-modal').style.display = 'flex'; } function closeConfirm() { document.getElementById('confirm-modal').style.display = 'none'; } // ── Toast ────────────────────────────────────────────────────────────────── function toast(msg, type = 'info') { const container = document.getElementById('toast-container'); const el = document.createElement('div'); el.className = `toast ${type}`; el.textContent = msg; container.appendChild(el); setTimeout(() => el.remove(), 4000); } // ── API usage ────────────────────────────────────────────────────────────── let _historyWindowMs = 3_600_000; // 1h — active only when panel is visible let _historyPanelVisible = false; async function loadApiUsage() { const bar = document.getElementById('api-usage-bar'); const label = document.getElementById('api-usage-label'); const sub = document.getElementById('api-usage-sub'); if (!bar) return; try { // Always fetch the current 60s count + 7-day limit-hit tally const { requests, limit, limitHits7d = 0 } = await api('GET', '/admin/syncro-usage'); // Update limit-hit badge on the History button const badge = document.getElementById('history-badge'); if (badge) { badge.textContent = limitHits7d > 999 ? '999+' : String(limitHits7d); badge.hidden = limitHits7d === 0; } let displayReq, displayLimit, pct; if (_historyPanelVisible) { // Fetch per-minute history for the selected window const { buckets } = await api('GET', `/admin/syncro-history?window=${_historyWindowMs}`); const windowTotal = (buckets ?? []).reduce((s, b) => s + b.count, 0); displayReq = windowTotal; displayLimit = Math.round(_historyWindowMs / 60_000) * limit; // minutes × 180 pct = Math.min(100, (windowTotal / displayLimit) * 100); drawUsageChart(buckets ?? []); } else { displayReq = requests; displayLimit = limit; pct = Math.min(100, (requests / limit) * 100); } bar.style.width = pct + '%'; bar.className = 'api-usage-bar' + (pct >= 80 ? ' crit' : pct >= 50 ? ' warn' : ''); label.textContent = displayReq === 0 ? `— / ${displayLimit.toLocaleString()}` : `${displayReq.toLocaleString()} / ${displayLimit.toLocaleString()}`; const timeStr = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); sub.textContent = `Updated ${timeStr} · auto-refreshes every 10s`; } catch (e) { if (sub) sub.textContent = 'Failed to load usage data.'; } } function _setHistoryWindowDescription() { const el = document.getElementById('api-usage-desc'); if (!el) return; if (!_historyPanelVisible) { el.textContent = 'Rolling 60-second request window'; return; } const labels = { 3600000: '1-hour', 86400000: '24-hour', 604800000: '7-day' }; el.textContent = `Rolling ${labels[_historyWindowMs] ?? ''} request window`; } function drawUsageChart(buckets) { const canvas = document.getElementById('usage-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Size the backing store to physical pixels for crispness const dpr = window.devicePixelRatio || 1; const displayW = canvas.offsetWidth; const displayH = canvas.offsetHeight; if (displayW === 0 || displayH === 0) return; canvas.width = displayW * dpr; canvas.height = displayH * dpr; ctx.scale(dpr, dpr); const W = displayW, H = displayH; const now = Date.now(); // Aggregation block size let blockMs; if (_historyWindowMs <= 3_600_000) blockMs = 60_000; // 1h → 1-min blocks (60 pts) else if (_historyWindowMs <= 86_400_000) blockMs = 10 * 60_000; // 24h → 10-min blocks (144 pts) else blockMs = 60 * 60_000; // 7d → 1-hour blocks (168 pts) // Build lookup map from raw per-minute buckets const bucketMap = new Map(buckets.map(b => [b.ts, b.count])); // Generate aggregated points (avg req/min in each block) const windowStart = now - _historyWindowMs; const firstBlockStart = Math.floor(windowStart / blockMs) * blockMs; const lastBlockStart = Math.floor(now / blockMs) * blockMs; const points = []; for (let t = firstBlockStart; t <= lastBlockStart; t += blockMs) { let total = 0, mins = 0; for (let m = t; m < t + blockMs; m += 60_000) { total += bucketMap.get(m) ?? 0; mins++; } points.push({ t, value: mins > 0 ? total / mins : 0 }); } if (points.length === 0) return; // Layout padding const padL = 28, padR = 8, padT = 10, padB = 20; const chartW = W - padL - padR; const chartH = H - padT - padB; const maxY = 180; const toX = i => padL + (points.length > 1 ? i / (points.length - 1) : 0.5) * chartW; const toY = v => padT + chartH * (1 - Math.min(v, maxY) / maxY); ctx.clearRect(0, 0, W, H); // Grid lines + Y-axis labels [0, 60, 120, 180].forEach(v => { const y = toY(v); ctx.strokeStyle = v === 0 ? '#d1d5db' : '#e5e7eb'; ctx.lineWidth = 1; ctx.setLineDash(v === 0 ? [] : [3, 3]); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = '#9ca3af'; ctx.font = '9px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(String(v), padL - 4, y); }); // Filled area gradient const gradient = ctx.createLinearGradient(0, padT, 0, padT + chartH); gradient.addColorStop(0, 'rgba(43,84,153,.22)'); gradient.addColorStop(1, 'rgba(43,84,153,.02)'); ctx.beginPath(); points.forEach((p, i) => { if (i === 0) ctx.moveTo(toX(i), toY(p.value)); else ctx.lineTo(toX(i), toY(p.value)); }); ctx.lineTo(toX(points.length - 1), padT + chartH); ctx.lineTo(toX(0), padT + chartH); ctx.closePath(); ctx.fillStyle = gradient; ctx.fill(); // Line ctx.beginPath(); points.forEach((p, i) => { if (i === 0) ctx.moveTo(toX(i), toY(p.value)); else ctx.lineTo(toX(i), toY(p.value)); }); ctx.strokeStyle = '#2b5499'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke(); // X-axis labels (5 evenly spaced) const labelCount = Math.min(5, points.length); ctx.fillStyle = '#9ca3af'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; for (let li = 0; li < labelCount; li++) { const idx = Math.round(li * (points.length - 1) / (labelCount - 1)); const d = new Date(points[idx].t); const label = blockMs >= 60 * 60_000 ? d.toLocaleDateString([], { month: 'short', day: 'numeric' }) : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); ctx.fillText(label, toX(idx), H - 3); } } // ── Utility ─────────────────────────────────────────────────────────────── function esc(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }