/* admin.js - Lagomare Gates admin panel */ // ── Token helpers ───────────────────────────────────────────────────────────── const TOKEN_KEY = "lg_admin_token"; const saveToken = t => localStorage.setItem(TOKEN_KEY, t); const clearToken = () => localStorage.removeItem(TOKEN_KEY); const getToken = () => localStorage.getItem(TOKEN_KEY); function tokenValid(t) { if (!t) return false; try { const p = JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); return p.exp * 1000 > Date.now(); } catch { return false; } } // ── API helper ──────────────────────────────────────────────────────────────── async function api(method, path, body) { const token = getToken(); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(path, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 401) { clearToken(); showLogin(); throw new Error("Session expired or invalid credentials"); } if (!res.ok) { const j = await res.json().catch(() => null); throw new Error((j && j.detail) || `Error ${res.status}`); } if (res.status === 204) return null; return res.json(); } // ── Views ───────────────────────────────────────────────────────────────────── function _tokenPayload() { try { const t = getToken(); return JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); } catch { return {}; } } function showLogin() { document.getElementById("login-view").classList.remove("hidden"); document.getElementById("admin-view").classList.add("hidden"); } function showAdmin() { document.getElementById("login-view").classList.add("hidden"); document.getElementById("admin-view").classList.remove("hidden"); const payload = _tokenPayload(); const isAdmin = payload.scope === "admin"; document.getElementById("header-username").textContent = payload.sub || ""; document.querySelectorAll(".admin-only").forEach(el => { el.style.display = isAdmin ? "" : "none"; }); loadAllData(); } // ── Login ───────────────────────────────────────────────────────────────────── let _pendingCredentials = null; // { username, password } while OTP step is shown document.getElementById("login-form").addEventListener("submit", async e => { e.preventDefault(); const errEl = document.getElementById("login-error"); const btn = e.target.querySelector("button[type=submit]"); const otpField = document.getElementById("otp-field"); btn.disabled = true; errEl.classList.add("hidden"); try { const body = _pendingCredentials ? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() } : { username: document.getElementById("admin-username").value.trim(), password: document.getElementById("admin-password").value }; const data = await api("POST", "/api/auth/admin", body); if (data.otp_required) { _pendingCredentials = { username: document.getElementById("admin-username").value.trim(), password: document.getElementById("admin-password").value, }; otpField.classList.remove("hidden"); document.getElementById("admin-otp").focus(); errEl.textContent = "Enter the 6-digit code from your authenticator app."; errEl.classList.remove("hidden"); } else { _pendingCredentials = null; otpField.classList.add("hidden"); document.getElementById("admin-otp").value = ""; saveToken(data.token); showAdmin(); } } catch (err) { _pendingCredentials = null; otpField.classList.add("hidden"); document.getElementById("admin-otp").value = ""; errEl.textContent = err.message; errEl.classList.remove("hidden"); } finally { btn.disabled = false; } }); document.getElementById("logout-btn").addEventListener("click", () => { clearToken(); showLogin(); }); // ── Tabs ────────────────────────────────────────────────────────────────────── document.querySelectorAll(".tab-btn").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); btn.classList.add("active"); document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); }); }); // ── Toast ───────────────────────────────────────────────────────────────────── let _timer; function showToast(msg, isError = false) { const el = document.getElementById("toast"); clearTimeout(_timer); el.textContent = msg; el.className = `toast ${isError ? "error" : "success"}`; _timer = setTimeout(() => el.classList.add("fade"), 2600); setTimeout(() => { el.className = "toast hidden"; }, 3000); } // ── Keypasses ───────────────────────────────────────────────────────────────── async function loadKeypasses() { const rows = await api("GET", "/api/admin/keypasses"); const tbody = document.getElementById("keypasses-body"); tbody.innerHTML = ""; if (!rows.length) { tbody.innerHTML = 'No keypasses yet'; return; } for (const kp of rows) { const now = Date.now(); const expMs = kp.expires_at ? new Date(kp.expires_at + "Z").getTime() : Infinity; let badge; if (kp.revoked) badge = 'Revoked'; else if (expMs < now) badge = 'Expired'; else badge = 'Active'; const gatesCell = kp.allowed_gate_ids && kp.allowed_gate_ids.length ? `${kp.allowed_gate_ids.length} gate${kp.allowed_gate_ids.length > 1 ? "s" : ""}` : 'All'; const expiresCell = kp.expires_at ? `${fmtDate(kp.expires_at)}` : 'Never'; const tr = document.createElement("tr"); tr.innerHTML = ` ${esc(kp.code)} ${esc(kp.description)} ${gatesCell} ${expiresCell} ${badge}
${!kp.revoked && expMs >= now ? `` : ""} ${!kp.revoked ? `` : ""} ${!kp.revoked && expMs >= now ? `` : ""}
`; tbody.appendChild(tr); } tbody.querySelectorAll("[data-edit-kp]").forEach(btn => { btn.addEventListener("click", () => { const kp = JSON.parse(btn.dataset.editKp); document.getElementById("kp-edit-id").value = kp.id; document.getElementById("kp-edit-desc").value = kp.description; // Expiry const neverCb = document.getElementById("kp-edit-never"); const expInput = document.getElementById("kp-edit-expires"); if (kp.expires_at) { neverCb.checked = false; expInput.disabled = false; expInput.style.opacity = ""; expInput.value = toLocalDatetimeInput(new Date(kp.expires_at + "Z")); } else { neverCb.checked = true; expInput.disabled = true; expInput.style.opacity = ".4"; expInput.value = ""; } // Gates const checksContainer = document.getElementById("kp-edit-gate-checks"); checksContainer.innerHTML = _allGates.length ? "" : 'No gates configured yet'; const allowedIds = kp.allowed_gate_ids && kp.allowed_gate_ids.length ? kp.allowed_gate_ids : null; for (const g of _allGates) { const lbl = document.createElement("label"); lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : ""; lbl.innerHTML = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`; checksContainer.appendChild(lbl); } const allGatesCb = document.getElementById("kp-edit-all-gates"); allGatesCb.checked = !allowedIds; checksContainer.style.display = allowedIds ? "flex" : "none"; document.getElementById("kp-edit-error").classList.add("hidden"); document.getElementById("kp-edit-modal").classList.remove("hidden"); }); }); tbody.querySelectorAll("[data-qr-kp-id]").forEach(btn => { btn.addEventListener("click", async () => { const id = btn.dataset.qrKpId; const desc = btn.dataset.qrKpDesc; document.getElementById("qr-modal-desc").textContent = desc; const img = document.getElementById("qr-img"); const dl = document.getElementById("qr-download"); img.src = ""; dl.removeAttribute("href"); document.getElementById("qr-modal").classList.remove("hidden"); try { const res = await fetch(`/api/admin/keypasses/${id}/qr`, { headers: { "Authorization": `Bearer ${getToken()}` }, }); if (!res.ok) throw new Error("Failed to load QR code"); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); img.src = blobUrl; dl.href = blobUrl; dl.download = `keypass-${id}-qr.png`; } catch (e) { showToast(e.message, true); document.getElementById("qr-modal").classList.add("hidden"); } }); }); tbody.querySelectorAll("[data-kp-id]").forEach(btn => { btn.addEventListener("click", async () => { if (!confirm("Revoke this keypass?")) return; try { await api("DELETE", `/api/admin/keypasses/${btn.dataset.kpId}`); showToast("Keypass revoked"); loadKeypasses(); } catch (e) { showToast(e.message, true); } }); }); } // New keypass modal let _allGates = []; document.getElementById("btn-new-keypass").addEventListener("click", () => { document.getElementById("kp-desc").value = ""; document.getElementById("kp-code").value = ""; // Reset complexity — default to passphrase document.getElementById("kp-charset").value = "passphrase"; document.getElementById("kp-length").value = 12; document.getElementById("kp-length-val").textContent = "12"; document.getElementById("kp-length-wrap").style.display = "none"; document.getElementById("kp-autogen-options").style.display = ""; // Reset never-expires const neverCb = document.getElementById("kp-never-expires"); neverCb.checked = false; const kpExpInput = document.getElementById("kp-expires"); kpExpInput.disabled = false; kpExpInput.required = true; kpExpInput.style.opacity = ""; const d = new Date(Date.now() + 7 * 86400_000); kpExpInput.value = toLocalDatetimeInput(d); // Render individual gate checkboxes const checksContainer = document.getElementById("kp-gate-checks"); checksContainer.innerHTML = _allGates.length ? "" : 'No gates configured yet'; for (const g of _allGates) { const lbl = document.createElement("label"); lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; lbl.innerHTML = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`; checksContainer.appendChild(lbl); } // Reset All gates checkbox document.getElementById("kp-all-gates").checked = true; checksContainer.style.display = "none"; document.getElementById("kp-error").classList.add("hidden"); document.getElementById("keypass-modal").classList.remove("hidden"); }); // Auto-generation options — hide when a manual code is typed, hide length for passphrase document.getElementById("kp-code").addEventListener("input", e => { document.getElementById("kp-autogen-options").style.display = e.target.value.trim() ? "none" : ""; }); document.getElementById("kp-charset").addEventListener("change", e => { document.getElementById("kp-length-wrap").style.display = e.target.value === "passphrase" ? "none" : ""; }); document.getElementById("kp-length").addEventListener("input", e => { document.getElementById("kp-length-val").textContent = e.target.value; }); // Never expires toggle document.getElementById("kp-never-expires").addEventListener("change", e => { const kpExpInput = document.getElementById("kp-expires"); kpExpInput.disabled = e.target.checked; kpExpInput.required = !e.target.checked; kpExpInput.style.opacity = e.target.checked ? ".4" : ""; }); // All gates toggle document.getElementById("kp-all-gates").addEventListener("change", e => { const checksContainer = document.getElementById("kp-gate-checks"); if (e.target.checked) { // Uncheck all individual gates and hide them checksContainer.querySelectorAll("input[name='kp-gate']").forEach(cb => cb.checked = false); checksContainer.style.display = "none"; } else { checksContainer.style.display = "flex"; } }); // Individual gate checkbox — uncheck "All gates" when any individual gate is checked document.getElementById("kp-gate-checks").addEventListener("change", () => { const anyChecked = document.getElementById("kp-gate-checks").querySelectorAll("input[name='kp-gate']:checked").length > 0; if (anyChecked) document.getElementById("kp-all-gates").checked = false; }); document.getElementById("kp-cancel").addEventListener("click", () => { document.getElementById("keypass-modal").classList.add("hidden"); }); // Keypass edit modal document.getElementById("kp-edit-never").addEventListener("change", e => { const expInput = document.getElementById("kp-edit-expires"); expInput.disabled = e.target.checked; expInput.style.opacity = e.target.checked ? ".4" : ""; }); document.getElementById("kp-edit-all-gates").addEventListener("change", e => { const checksContainer = document.getElementById("kp-edit-gate-checks"); if (e.target.checked) { checksContainer.querySelectorAll("input[name='kp-edit-gate']").forEach(cb => cb.checked = false); checksContainer.style.display = "none"; } else { checksContainer.style.display = "flex"; } }); document.getElementById("kp-edit-gate-checks").addEventListener("change", () => { const anyChecked = document.getElementById("kp-edit-gate-checks").querySelectorAll("input[name='kp-edit-gate']:checked").length > 0; if (anyChecked) document.getElementById("kp-edit-all-gates").checked = false; }); document.getElementById("kp-edit-cancel").addEventListener("click", () => { document.getElementById("kp-edit-modal").classList.add("hidden"); }); document.getElementById("qr-close").addEventListener("click", () => { const img = document.getElementById("qr-img"); if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src); img.src = ""; document.getElementById("qr-modal").classList.add("hidden"); }); document.getElementById("kp-edit-form").addEventListener("submit", async e => { e.preventDefault(); const id = document.getElementById("kp-edit-id").value; const description = document.getElementById("kp-edit-desc").value.trim(); const never = document.getElementById("kp-edit-never").checked; const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString(); const allGates = document.getElementById("kp-edit-all-gates").checked; const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value)); const errEl = document.getElementById("kp-edit-error"); errEl.classList.add("hidden"); try { await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids }); document.getElementById("kp-edit-modal").classList.add("hidden"); showToast("Keypass updated"); loadKeypasses(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); document.getElementById("keypass-form").addEventListener("submit", async e => { e.preventDefault(); const desc = document.getElementById("kp-desc").value.trim(); const code = document.getElementById("kp-code").value.trim() || null; const charset = document.getElementById("kp-charset").value; const length = parseInt(document.getElementById("kp-length").value, 10); const neverExpires = document.getElementById("kp-never-expires").checked; const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString(); const allGates = document.getElementById("kp-all-gates").checked; const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value)); const errEl = document.getElementById("kp-error"); errEl.classList.add("hidden"); try { await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length }); document.getElementById("keypass-modal").classList.add("hidden"); showToast("Keypass created"); loadKeypasses(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); // ── Gates ───────────────────────────────────────────────────────────────────── async function loadGates() { const rows = await api("GET", "/api/admin/gates"); _allGates = rows; // cache for keypass modal // Populate the stats gate filter dropdown const filterGate = document.getElementById("filter-gate"); const prevGateVal = filterGate.value; filterGate.innerHTML = ''; for (const g of rows) { const opt = document.createElement("option"); opt.value = g.id; opt.textContent = g.name; filterGate.appendChild(opt); } filterGate.value = prevGateVal; // restore selection if still valid const isAdmin = _tokenPayload().scope === "admin"; const tbody = document.getElementById("gates-body"); tbody.innerHTML = ""; if (!rows.length) { tbody.innerHTML = 'No gates yet'; return; } for (const g of rows) { const badge = g.status === "enabled" ? 'Enabled' : 'Disabled'; const tr = document.createElement("tr"); tr.innerHTML = ` ${g.id} ${esc(g.name)} ${g.group_name ? esc(g.group_name) : ''} ${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"} ${esc(g.avconnect_macro_id)} ${badge}
${g.status === 'enabled' ? `` : ''} ${isAdmin ? ` ` : ''}
`; tbody.appendChild(tr); } tbody.querySelectorAll("[data-open-id]").forEach(btn => { btn.addEventListener("click", async () => { btn.disabled = true; const orig = btn.textContent; btn.textContent = "…"; try { const res = await api("POST", `/api/admin/gates/${btn.dataset.openId}/open`); showToast(`${res.gate} opened ✓`); } catch (e) { showToast(e.message, true); } finally { btn.disabled = false; btn.textContent = orig; } }); }); tbody.querySelectorAll("[data-edit-id]").forEach(btn => { btn.addEventListener("click", () => openGateModal(JSON.parse(btn.dataset.gate))); }); tbody.querySelectorAll("[data-del-id]").forEach(btn => { btn.addEventListener("click", async () => { if (!confirm("Delete this gate?")) return; try { await api("DELETE", `/api/admin/gates/${encodeURIComponent(btn.dataset.delId)}`); showToast("Gate deleted"); loadGates(); } catch (e) { showToast(e.message, true); } }); }); } function openGateModal(gate = null) { document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate"; document.getElementById("gate-edit-id").value = gate ? gate.id : ""; document.getElementById("gate-name").value = gate ? gate.name : ""; document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : ""; document.getElementById("gate-type").value = gate ? gate.gate_type : "car"; document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : ""; document.getElementById("gate-status").value = gate ? gate.status : "enabled"; document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : ""; document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : ""; document.getElementById("gate-error").classList.add("hidden"); // Populate group suggestions from existing gates const dl = document.getElementById("gate-group-list"); dl.innerHTML = ""; const groups = [...new Set((_allGates || []).map(g => g.group_name).filter(Boolean))].sort(); for (const g of groups) { const opt = document.createElement("option"); opt.value = g; dl.appendChild(opt); } document.getElementById("gate-modal").classList.remove("hidden"); } document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal()); document.getElementById("gate-cancel").addEventListener("click", () => { document.getElementById("gate-modal").classList.add("hidden"); }); document.getElementById("gate-form").addEventListener("submit", async e => { e.preventDefault(); const editId = document.getElementById("gate-edit-id").value; const payload = { name: document.getElementById("gate-name").value.trim(), gate_type: document.getElementById("gate-type").value, avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(), status: document.getElementById("gate-status").value, group_name: document.getElementById("gate-group-name").value.trim() || null, lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null, lon: document.getElementById("gate-lon").value !== "" ? parseFloat(document.getElementById("gate-lon").value) : null, }; const errEl = document.getElementById("gate-error"); errEl.classList.add("hidden"); try { if (editId) { await api("PUT", `/api/admin/gates/${encodeURIComponent(editId)}`, payload); } else { await api("POST", "/api/admin/gates", payload); } document.getElementById("gate-modal").classList.add("hidden"); showToast("Gate saved"); loadGates(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); // ── Credentials ─────────────────────────────────────────────────────────────── async function loadCredentials() { try { const list = await api("GET", "/api/admin/credentials"); if (list.length) { document.getElementById("cred-username").value = list[0].username; } } catch { /* no creds yet */ } try { const { enabled } = await api("GET", "/api/admin/credentials/mock"); document.getElementById("mock-toggle").checked = enabled; } catch { /* ignore */ } } document.getElementById("mock-toggle").addEventListener("change", async e => { try { await api("PUT", "/api/admin/credentials/mock", { enabled: e.target.checked }); showToast(e.target.checked ? "Mock mode enabled" : "Mock mode disabled"); } catch (err) { showToast(err.message, true); e.target.checked = !e.target.checked; // revert } }); document.getElementById("credentials-form").addEventListener("submit", async e => { e.preventDefault(); const username = document.getElementById("cred-username").value.trim(); const password = document.getElementById("cred-password").value; const errEl = document.getElementById("cred-error"); errEl.classList.add("hidden"); if (!password) { errEl.textContent = "Password is required."; errEl.classList.remove("hidden"); return; } try { await api("PUT", "/api/admin/credentials", { username, password }); document.getElementById("cred-password").value = ""; showToast("Credentials saved"); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); // ── Statistics ─────────────────────────────────────────────────────────────── const STATS_PAGE_SIZE = 50; let _statsPage = 1; let _statsTotal = 0; function _buildStatsParams() { const params = new URLSearchParams(); const keypass = document.getElementById("filter-keypass").value.trim(); if (keypass) params.set("keypass_code", keypass.toUpperCase()); const gate = document.getElementById("filter-gate").value; if (gate) params.set("gate_id", gate); const success = document.getElementById("filter-success").value; if (success !== "") params.set("success", success); const from = document.getElementById("filter-from").value; if (from) params.set("date_from", new Date(from).toISOString()); const to = document.getElementById("filter-to").value; if (to) params.set("date_to", new Date(to).toISOString()); params.set("page", _statsPage); params.set("page_size", STATS_PAGE_SIZE); return params; } async function loadStats() { try { const data = await api("GET", `/api/admin/stats?${_buildStatsParams()}`); _statsTotal = data.total; const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE)); document.getElementById("stats-total-label").textContent = `${_statsTotal} record${_statsTotal !== 1 ? "s" : ""}`; document.getElementById("stats-page-label").textContent = `Page ${_statsPage} of ${totalPages}`; document.getElementById("btn-stats-prev").disabled = _statsPage <= 1; document.getElementById("btn-stats-next").disabled = _statsPage >= totalPages; const tbody = document.getElementById("stats-body"); tbody.innerHTML = ""; if (!data.items.length) { tbody.innerHTML = 'No records match the current filters'; return; } for (const r of data.items) { const badge = r.success ? 'OK' : `Fail`; const tr = document.createElement("tr"); tr.innerHTML = ` ${fmtDate(r.timestamp.replace(' ', 'T'))} ${esc(r.keypass_code)} ${esc(r.gate_name)} ${esc(r.ip_address || '-')} ${esc(r.user_agent || '-')} ${badge}`; tbody.appendChild(tr); } } catch (e) { showToast(e.message, true); } } document.getElementById("btn-refresh-stats").addEventListener("click", () => { _statsPage = 1; loadStats(); }); document.getElementById("btn-stats-filter").addEventListener("click", () => { _statsPage = 1; loadStats(); }); document.getElementById("btn-stats-reset").addEventListener("click", () => { document.getElementById("filter-keypass").value = ""; document.getElementById("filter-gate").value = ""; document.getElementById("filter-success").value = ""; document.getElementById("filter-from").value = ""; document.getElementById("filter-to").value = ""; _statsPage = 1; loadStats(); }); document.getElementById("btn-stats-prev").addEventListener("click", () => { if (_statsPage > 1) { _statsPage--; loadStats(); } }); document.getElementById("btn-stats-next").addEventListener("click", () => { const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE)); if (_statsPage < totalPages) { _statsPage++; loadStats(); } }); // ── Admin users ─────────────────────────────────────────────────────────────── async function loadAdmins() { const rows = await api("GET", "/api/admin/admins"); const tbody = document.getElementById("admins-body"); tbody.innerHTML = ""; if (!rows.length) { tbody.innerHTML = 'No admins'; return; } const me = _tokenPayload().sub; for (const u of rows) { const roleBadge = u.role === "admin" ? 'admin' : 'manager'; const totpBadge = u.totp_enabled ? '2FA ✓' : ''; const is_me = u.username === me; const totpBtn = is_me ? `` : ""; const tr = document.createElement("tr"); tr.innerHTML = `
${esc(u.username)}${is_me ? 'you' : ""} ${roleBadge} ${totpBadge}
${totpBtn} ${!is_me ? `` : ""}
`; tbody.appendChild(tr); } tbody.querySelectorAll("[data-totp]").forEach(btn => { btn.addEventListener("click", async () => { const username = btn.dataset.totp; const enabled = btn.dataset.totpEnabled === "true"; if (enabled) { if (!confirm("Disable two-factor authentication for your account?")) return; try { await api("DELETE", `/api/admin/admins/${encodeURIComponent(username)}/totp`); showToast("2FA disabled"); loadAdmins(); } catch (e) { showToast(e.message, true); } } else { openTotpSetup(username); } }); }); tbody.querySelectorAll("[data-chpw]").forEach(btn => { btn.addEventListener("click", () => { document.getElementById("chpw-username").value = btn.dataset.chpw; document.getElementById("chpw-new").value = ""; document.getElementById("chpw-confirm").value = ""; document.getElementById("chpw-error").classList.add("hidden"); document.getElementById("chpw-modal").classList.remove("hidden"); }); }); tbody.querySelectorAll("[data-del-admin]").forEach(btn => { btn.addEventListener("click", async () => { if (!confirm(`Delete admin "${btn.dataset.delAdmin}"?`)) return; try { await api("DELETE", `/api/admin/admins/${encodeURIComponent(btn.dataset.delAdmin)}`); showToast("Admin deleted"); loadAdmins(); } catch (e) { showToast(e.message, true); } }); }); } // ── TOTP setup modal ────────────────────────────────────────────────────────── let _totpUsername = null; async function openTotpSetup(username) { _totpUsername = username; document.getElementById("totp-confirm-code").value = ""; document.getElementById("totp-error").classList.add("hidden"); document.getElementById("totp-modal").classList.remove("hidden"); try { const data = await api("POST", `/api/admin/admins/${encodeURIComponent(username)}/totp/setup`); // Render QR from base64 PNG returned by the server const canvas = document.getElementById("totp-qr-canvas"); const img = new Image(); img.onload = () => { canvas.width = img.width; canvas.height = img.height; canvas.style.width = Math.min(img.width, 220) + "px"; canvas.style.height = "auto"; canvas.getContext("2d").drawImage(img, 0, 0); }; img.src = "data:image/png;base64," + data.qr_image_b64; document.getElementById("totp-uri-fallback").textContent = data.provisioning_uri; } catch (e) { document.getElementById("totp-error").textContent = e.message; document.getElementById("totp-error").classList.remove("hidden"); } } document.getElementById("totp-cancel").addEventListener("click", () => { document.getElementById("totp-modal").classList.add("hidden"); _totpUsername = null; }); document.getElementById("totp-confirm-btn").addEventListener("click", async () => { if (!_totpUsername) return; const code = document.getElementById("totp-confirm-code").value.trim(); const errEl = document.getElementById("totp-error"); errEl.classList.add("hidden"); if (!code) { errEl.textContent = "Enter the 6-digit code from your app."; errEl.classList.remove("hidden"); return; } try { await api("POST", `/api/admin/admins/${encodeURIComponent(_totpUsername)}/totp/enable`, { otp_code: code }); document.getElementById("totp-modal").classList.add("hidden"); _totpUsername = null; showToast("Two-factor authentication enabled"); loadAdmins(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); document.getElementById("btn-new-admin").addEventListener("click", () => { document.getElementById("admin-new-username").value = ""; document.getElementById("admin-new-password").value = ""; document.getElementById("admin-new-role").value = "admin"; document.getElementById("admin-modal-error").classList.add("hidden"); document.getElementById("admin-modal").classList.remove("hidden"); }); document.getElementById("admin-cancel").addEventListener("click", () => { document.getElementById("admin-modal").classList.add("hidden"); }); document.getElementById("admin-form").addEventListener("submit", async e => { e.preventDefault(); const username = document.getElementById("admin-new-username").value.trim(); const password = document.getElementById("admin-new-password").value; const role = document.getElementById("admin-new-role").value; const errEl = document.getElementById("admin-modal-error"); errEl.classList.add("hidden"); try { await api("POST", "/api/admin/admins", { username, password, role }); document.getElementById("admin-modal").classList.add("hidden"); showToast("Admin created"); loadAdmins(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); // ── Change password modal ───────────────────────────────────────────────────── document.getElementById("chpw-cancel").addEventListener("click", () => { document.getElementById("chpw-modal").classList.add("hidden"); }); document.getElementById("chpw-form").addEventListener("submit", async e => { e.preventDefault(); const username = document.getElementById("chpw-username").value; const newPw = document.getElementById("chpw-new").value; const confirm = document.getElementById("chpw-confirm").value; const errEl = document.getElementById("chpw-error"); errEl.classList.add("hidden"); if (newPw !== confirm) { errEl.textContent = "Passwords do not match"; errEl.classList.remove("hidden"); return; } try { await api("PATCH", `/api/admin/admins/${encodeURIComponent(username)}/password`, { new_password: newPw }); document.getElementById("chpw-modal").classList.add("hidden"); showToast("Password updated"); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); // ── Telegram / Notifications ────────────────────────────────────────────────── async function loadTelegram() { try { const cfg = await api("GET", "/api/admin/telegram"); document.getElementById("tg-chat-id").value = cfg.chat_id || ""; document.getElementById("tg-enabled").checked = cfg.enabled; document.getElementById("tg-status").textContent = cfg.configured ? "Bot token is saved. Leave the token field empty to keep it." : "Not configured yet. Enter a bot token to get started."; } catch { /* non-admin: tab hidden anyway */ } } document.getElementById("telegram-form").addEventListener("submit", async e => { e.preventDefault(); const token = document.getElementById("tg-token").value.trim() || null; const chat_id = document.getElementById("tg-chat-id").value.trim(); const enabled = document.getElementById("tg-enabled").checked; const errEl = document.getElementById("tg-error"); errEl.classList.add("hidden"); try { await api("PUT", "/api/admin/telegram", { bot_token: token, chat_id, enabled }); document.getElementById("tg-token").value = ""; showToast("Telegram settings saved"); loadTelegram(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } }); document.getElementById("btn-tg-test").addEventListener("click", async () => { const btn = document.getElementById("btn-tg-test"); const errEl = document.getElementById("tg-error"); btn.disabled = true; errEl.classList.add("hidden"); try { await api("POST", "/api/admin/telegram/test"); showToast("Test message sent ✓"); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } finally { btn.disabled = false; } }); // ── Load all data ───────────────────────────────────────────────────────────── function loadAllData() { const isAdmin = _tokenPayload().scope === "admin"; loadKeypasses(); loadGates(); loadStats(); if (isAdmin) { loadCredentials(); loadAdmins(); loadTelegram(); } } // ── Utilities ───────────────────────────────────────────────────────────────── function esc(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmtDate(iso) { const d = new Date(iso + "Z"); const pad = n => String(n).padStart(2, "0"); return `${pad(d.getDate())}/${pad(d.getMonth()+1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function toLocalDatetimeInput(date) { const pad = n => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** Parse "dd/mm/yyyy hh:mm" as local time and return a UTC ISO string, or null on error. */ function parseLocalDdMmYyyy(str) { const m = str.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2})$/); if (!m) return null; const d = new Date(+m[3], +m[2] - 1, +m[1], +m[4], +m[5]); return isNaN(d.getTime()) ? null : d.toISOString(); } // ── Init ────────────────────────────────────────────────────────────────────── (function init() { const t = getToken(); if (tokenValid(t)) { showAdmin(); } else { clearToken(); showLogin(); } })();