Admins can change passwords. Request user confirmation to open gate

This commit is contained in:
Ettore
2026-05-06 11:22:43 +02:00
parent 2b598279d0
commit da97027606
7 changed files with 127 additions and 9 deletions

View File

@@ -107,7 +107,10 @@
<div id="admin-view" class="hidden">
<header class="app-header">
<h2>⚙️ Admin - Lagomare Gates</h2>
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Logout</button>
<div style="display:flex;align-items:center;gap:.75rem">
<span id="header-username" style="font-size:.85rem;color:var(--text-muted)"></span>
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Logout</button>
</div>
</header>
<nav class="tabs">
@@ -347,6 +350,29 @@
</div>
</div>
<!-- ── Change password modal ─────────────────────────────────────────────── -->
<div id="chpw-modal" class="modal-backdrop hidden">
<div class="modal">
<h3>Change Password</h3>
<form id="chpw-form">
<input type="hidden" id="chpw-username" />
<div class="field">
<label for="chpw-new">New password</label>
<input id="chpw-new" type="password" autocomplete="new-password" required />
</div>
<div class="field">
<label for="chpw-confirm">Confirm password</label>
<input id="chpw-confirm" type="password" autocomplete="new-password" required />
</div>
<p id="chpw-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="chpw-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- ── Admin user modal ──────────────────────────────────────────────────── -->
<div id="admin-modal" class="modal-backdrop hidden">
<div class="modal">

View File

@@ -27,7 +27,7 @@ async function api(method, path, body) {
if (res.status === 401) {
clearToken();
showLogin();
throw new Error("Session expired.");
throw new Error("Session expired or invalid credentials");
}
if (!res.ok) {
const j = await res.json().catch(() => null);
@@ -53,7 +53,9 @@ function showLogin() {
function showAdmin() {
document.getElementById("login-view").classList.add("hidden");
document.getElementById("admin-view").classList.remove("hidden");
const isAdmin = _tokenPayload().scope === "admin";
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";
});
@@ -504,11 +506,21 @@ async function loadAdmins() {
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">
<td style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
${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-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;
@@ -549,6 +561,32 @@ document.getElementById("admin-form").addEventListener("submit", async e => {
}
});
// ── 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");
}
});
// ── Load all data ─────────────────────────────────────────────────────────────
function loadAllData() {
const isAdmin = _tokenPayload().scope === "admin";

View File

@@ -28,7 +28,7 @@ async function apiFetch(method, path, body) {
if (res.status === 401) {
clearToken();
showLogin();
throw new Error("Session expired - please log in again.");
throw new Error("Session expired or invalid keypass");
}
if (!res.ok) {
const json = await res.json().catch(() => null);
@@ -71,7 +71,7 @@ function renderGates(gates) {
btn.className = `gate-btn ${gate.gate_type}`;
btn.dataset.gateId = gate.id;
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
btn.addEventListener("click", () => handleOpenGate(btn, gate.id));
btn.addEventListener("click", () => handleOpenGate(btn, gate));
grid.appendChild(btn);
}
}
@@ -87,7 +87,28 @@ async function loadGates() {
}
// ── Open gate action ──────────────────────────────────────────────────────────
async function handleOpenGate(btn, gateId) {
let _pendingGate = null;
document.getElementById("confirm-cancel").addEventListener("click", () => {
document.getElementById("confirm-modal").classList.add("hidden");
_pendingGate = null;
});
document.getElementById("confirm-ok").addEventListener("click", () => {
document.getElementById("confirm-modal").classList.add("hidden");
if (_pendingGate) {
const { btn, gateId } = _pendingGate;
_pendingGate = null;
_doOpenGate(btn, gateId);
}
});
async function handleOpenGate(btn, gate) {
_pendingGate = { btn, gateId: gate.id };
document.getElementById("confirm-gate-name").textContent = gate.name;
document.getElementById("confirm-modal").classList.remove("hidden");
}
async function _doOpenGate(btn, gateId) {
btn.disabled = true;
btn.classList.add("loading");
btn.classList.remove("ok", "fail");

View File

@@ -131,6 +131,18 @@
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div>
<!-- ── Confirm open modal ─────────────────────────────────────────────── -->
<div id="confirm-modal" class="modal-backdrop hidden">
<div class="modal" style="max-width:320px;text-align:center">
<p style="font-size:1.1rem;font-weight:700;margin-bottom:.25rem" id="confirm-gate-name"></p>
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1.5rem">Open this gate?</p>
<div style="display:flex;gap:.75rem;justify-content:center">
<button id="confirm-cancel" class="btn btn-ghost" style="flex:1">Cancel</button>
<button id="confirm-ok" class="btn btn-primary" style="flex:1">Open</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>