Compare commits
2 Commits
2b598279d0
...
89e5c1ac7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e5c1ac7e | ||
|
|
da97027606 |
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
data/*.db-journal
|
||||||
@@ -8,5 +8,8 @@ SECRET_KEY=replace-with-a-random-64-char-hex-string
|
|||||||
# Set to true to skip real AVConnect calls (for testing)
|
# Set to true to skip real AVConnect calls (for testing)
|
||||||
# MOCK_AVCONNECT=true
|
# MOCK_AVCONNECT=true
|
||||||
|
|
||||||
|
# Port configuration
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
# Database path (default: ./data/gates.db relative to project root)
|
# Database path (default: ./data/gates.db relative to project root)
|
||||||
# DATABASE_URL=sqlite:///./data/gates.db
|
# DATABASE_URL=sqlite:///./data/gates.db
|
||||||
|
|||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Use Python 3.11 slim image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies if needed
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the source code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY data/ ./data/
|
||||||
|
|
||||||
|
# Create data directory if it doesn't exist
|
||||||
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE $APP_PORT
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "$APP_PORT"]
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- ADMIN_USERNAME=admin
|
||||||
|
- ADMIN_PASSWORD=changeme
|
||||||
|
- SECRET_KEY=supersecretkey
|
||||||
|
- MOCK_AVCONNECT=false
|
||||||
|
- APP_PORT=8000
|
||||||
|
restart: unless-stopped
|
||||||
@@ -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