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,6 +107,10 @@ class AdminUserCreate(BaseModel):
role: str = "admin" # 'admin' | 'manager' role: str = "admin" # 'admin' | 'manager'
class AdminPasswordChange(BaseModel):
new_password: str
# ── Statistics ──────────────────────────────────────────────────────────────── # ── Statistics ────────────────────────────────────────────────────────────────
class AccessLogResponse(BaseModel): class AccessLogResponse(BaseModel):

View File

@@ -95,4 +95,5 @@ def _seed_admin() -> None:
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) port = int(os.environ.get("APP_PORT", 8000))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from core.auth import hash_password from core.auth import hash_password
from core.database import AdminUser, get_db from core.database import AdminUser, get_db
from core.dependencies import require_admin from core.dependencies import require_admin
from core.schemas import AdminUserCreate, AdminUserResponse from core.schemas import AdminUserCreate, AdminUserResponse, AdminPasswordChange
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"]) router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
@@ -53,3 +53,19 @@ async def delete_admin(
raise HTTPException(409, "Cannot delete the last admin account") raise HTTPException(409, "Cannot delete the last admin account")
db.delete(user) db.delete(user)
db.commit() db.commit()
@router.patch("/{username}/password", status_code=204)
async def change_password(
username: str,
req: AdminPasswordChange,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
if not req.new_password:
raise HTTPException(422, "Password cannot be empty")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user:
raise HTTPException(404, "Admin not found")
user.password_hash = hash_password(req.new_password)
db.commit()

View File

@@ -107,7 +107,10 @@
<div id="admin-view" class="hidden"> <div id="admin-view" class="hidden">
<header class="app-header"> <header class="app-header">
<h2>⚙️ Admin - Lagomare Gates</h2> <h2>⚙️ Admin - Lagomare Gates</h2>
<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> <button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Logout</button>
</div>
</header> </header>
<nav class="tabs"> <nav class="tabs">
@@ -347,6 +350,29 @@
</div> </div>
</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 ──────────────────────────────────────────────────── --> <!-- ── Admin user modal ──────────────────────────────────────────────────── -->
<div id="admin-modal" class="modal-backdrop hidden"> <div id="admin-modal" class="modal-backdrop hidden">
<div class="modal"> <div class="modal">

View File

@@ -27,7 +27,7 @@ async function api(method, path, body) {
if (res.status === 401) { if (res.status === 401) {
clearToken(); clearToken();
showLogin(); showLogin();
throw new Error("Session expired."); throw new Error("Session expired or invalid credentials");
} }
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => null); const j = await res.json().catch(() => null);
@@ -53,7 +53,9 @@ function showLogin() {
function showAdmin() { function showAdmin() {
document.getElementById("login-view").classList.add("hidden"); document.getElementById("login-view").classList.add("hidden");
document.getElementById("admin-view").classList.remove("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 => { document.querySelectorAll(".admin-only").forEach(el => {
el.style.display = isAdmin ? "" : "none"; el.style.display = isAdmin ? "" : "none";
}); });
@@ -504,11 +506,21 @@ async function loadAdmins() {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td> <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>` : ""} ${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
</td>`; </td>`;
tbody.appendChild(tr); 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 => { tbody.querySelectorAll("[data-del-admin]").forEach(btn => {
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
if (!confirm(`Delete admin "${btn.dataset.delAdmin}"?`)) return; 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 ───────────────────────────────────────────────────────────── // ── Load all data ─────────────────────────────────────────────────────────────
function loadAllData() { function loadAllData() {
const isAdmin = _tokenPayload().scope === "admin"; const isAdmin = _tokenPayload().scope === "admin";

View File

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

View File

@@ -131,6 +131,18 @@
<!-- ── Toast ───────────────────────────────────────────────────────────── --> <!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div> <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> <script src="/static/app.js"></script>
</body> </body>
</html> </html>