First commit
This commit is contained in:
601
src/static/admin.js
Normal file
601
src/static/admin.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/* 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.");
|
||||
}
|
||||
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 isAdmin = _tokenPayload().scope === "admin";
|
||||
document.querySelectorAll(".admin-only").forEach(el => {
|
||||
el.style.display = isAdmin ? "" : "none";
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||
document.getElementById("login-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById("admin-username").value.trim();
|
||||
const password = document.getElementById("admin-password").value;
|
||||
const errEl = document.getElementById("login-error");
|
||||
const btn = e.target.querySelector("button[type=submit]");
|
||||
btn.disabled = true;
|
||||
errEl.classList.add("hidden");
|
||||
try {
|
||||
const data = await api("POST", "/api/auth/admin", { username, password });
|
||||
saveToken(data.token);
|
||||
showAdmin();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.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 style="text-align:right">
|
||||
${!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>` : ""}
|
||||
</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-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 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");
|
||||
});
|
||||
|
||||
// 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("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 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 });
|
||||
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
|
||||
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.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
|
||||
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
|
||||
<td>${badge}</td>
|
||||
<td 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>` : ''}
|
||||
</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-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-error").classList.add("hidden");
|
||||
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,
|
||||
};
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
async function loadStats() {
|
||||
try {
|
||||
const rows = await api("GET", "/api/admin/stats");
|
||||
const tbody = document.getElementById("stats-body");
|
||||
tbody.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No access logs yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
for (const r of rows) {
|
||||
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", loadStats);
|
||||
|
||||
document.getElementById("btn-refresh-stats").addEventListener("click", 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 tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td>
|
||||
<td style="text-align:right">
|
||||
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
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); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Load all data ─────────────────────────────────────────────────────────────
|
||||
function loadAllData() {
|
||||
const isAdmin = _tokenPayload().scope === "admin";
|
||||
loadKeypasses();
|
||||
loadGates();
|
||||
loadStats();
|
||||
if (isAdmin) {
|
||||
loadCredentials();
|
||||
loadAdmins();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user