Files
lagomareGates/src/static/admin.js
2026-05-10 17:56:49 +02:00

926 lines
42 KiB
JavaScript

/* 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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
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 = '<span class="badge badge-red">Revoked</span>';
else if (expMs < now) badge = '<span class="badge badge-muted">Expired</span>';
else badge = '<span class="badge badge-green">Active</span>';
const gatesCell = kp.allowed_gate_ids && kp.allowed_gate_ids.length
? `${kp.allowed_gate_ids.length} gate${kp.allowed_gate_ids.length > 1 ? "s" : ""}`
: '<span style="color:var(--text-muted)">All</span>';
const expiresCell = kp.expires_at
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
: '<span style="color:var(--text-muted)">Never</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
<td>${esc(kp.description)}</td>
<td>${gatesCell}</td>
<td>${expiresCell}</td>
<td>${badge}</td>
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-kp-id="${kp.id}">Revoke</button>` : ""}
</div></td>`;
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
? ""
: '<span style="color:var(--text-muted);font-size:.85em;padding:.25rem .75rem">No gates configured yet</span>';
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 = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
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
? ""
: '<span style="color:var(--text-muted);font-size:.85em;padding:.25rem .75rem">No gates configured yet</span>';
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 = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
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 = '<option value="">Any</option>';
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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No gates yet</td></tr>';
return;
}
for (const g of rows) {
const badge = g.status === "enabled"
? '<span class="badge badge-green">Enabled</span>'
: '<span class="badge badge-muted">Disabled</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${g.id}</td>
<td>${esc(g.name)}</td>
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">—</span>'}</td>
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
<td>${badge}</td>
<td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
data-open-id="${g.id}">Open</button>` : ''}
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-del-id="${g.id}">Delete</button>` : ''}
</div></td>`;
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 = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No records match the current filters</td></tr>';
return;
}
for (const r of data.items) {
const badge = r.success
? '<span class="badge badge-green">OK</span>'
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
const tr = document.createElement("tr");
tr.innerHTML = `
<td style="white-space:nowrap">${fmtDate(r.timestamp.replace(' ', 'T'))}</td>
<td><code style="font-size:.85em">${esc(r.keypass_code)}</code></td>
<td>${esc(r.gate_name)}</td>
<td style="font-size:.85em;font-family:monospace">${esc(r.ip_address || '-')}</td>
<td style="font-size:.8em;color:var(--text-muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.user_agent || '')}">${esc(r.user_agent || '-')}</td>
<td>${badge}</td>`;
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 = '<tr><td colspan="2" style="color:var(--text-muted);text-align:center;padding:2rem">No admins</td></tr>';
return;
}
const me = _tokenPayload().sub;
for (const u of rows) {
const roleBadge = u.role === "admin"
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
const totpBadge = u.totp_enabled
? '<span class="badge badge-green" style="font-size:.75em;background:var(--accent-warn,#b45309);color:#fff" title="2FA enabled">2FA ✓</span>'
: '';
const is_me = u.username === me;
const totpBtn = is_me
? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-totp="${esc(u.username)}" data-totp-enabled="${u.totp_enabled}">${u.totp_enabled ? "Disable 2FA" : "Enable 2FA"}</button>`
: "";
const tr = document.createElement("tr");
tr.innerHTML = `
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${is_me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge} ${totpBadge}</div></td>
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
${totpBtn}
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
${!is_me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
</div></td>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
}
})();