Admins can change passwords. Request user confirmation to open gate
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user