commit 78fca8ebc210e3ff5c58bf5525845098fb9cb844
Author: Ettore <=>
Date: Wed May 6 01:51:22 2026 +0200
First commit
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ee6b63a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,12 @@
+# Admin credentials (required on first run to seed the admin user)
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=changeme123
+
+# JWT signing secret – generate with: python -c "import secrets; print(secrets.token_hex(32))"
+SECRET_KEY=replace-with-a-random-64-char-hex-string
+
+# Set to true to skip real AVConnect calls (for testing)
+# MOCK_AVCONNECT=true
+
+# Database path (default: ./data/gates.db relative to project root)
+# DATABASE_URL=sqlite:///./data/gates.db
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c87dabe
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.111.0
+uvicorn[standard]>=0.29.0
+sqlalchemy>=2.0.0
+python-jose[cryptography]>=3.3.0
+bcrypt>=4.0.0
+cryptography>=42.0.0
+requests>=2.31.0
+fake-useragent>=1.5.0
+python-multipart>=0.0.9
diff --git a/src/__pycache__/auth.cpython-313.pyc b/src/__pycache__/auth.cpython-313.pyc
new file mode 100644
index 0000000..7df92b3
Binary files /dev/null and b/src/__pycache__/auth.cpython-313.pyc differ
diff --git a/src/__pycache__/database.cpython-313.pyc b/src/__pycache__/database.cpython-313.pyc
new file mode 100644
index 0000000..a26b410
Binary files /dev/null and b/src/__pycache__/database.cpython-313.pyc differ
diff --git a/src/__pycache__/dependencies.cpython-313.pyc b/src/__pycache__/dependencies.cpython-313.pyc
new file mode 100644
index 0000000..a6d2416
Binary files /dev/null and b/src/__pycache__/dependencies.cpython-313.pyc differ
diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc
new file mode 100644
index 0000000..0a8001f
Binary files /dev/null and b/src/__pycache__/main.cpython-313.pyc differ
diff --git a/src/__pycache__/schemas.cpython-313.pyc b/src/__pycache__/schemas.cpython-313.pyc
new file mode 100644
index 0000000..4d6b223
Binary files /dev/null and b/src/__pycache__/schemas.cpython-313.pyc differ
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..b15afc8
Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/core/__pycache__/auth.cpython-313.pyc b/src/core/__pycache__/auth.cpython-313.pyc
new file mode 100644
index 0000000..1d58008
Binary files /dev/null and b/src/core/__pycache__/auth.cpython-313.pyc differ
diff --git a/src/core/__pycache__/config.cpython-313.pyc b/src/core/__pycache__/config.cpython-313.pyc
new file mode 100644
index 0000000..05ad2dd
Binary files /dev/null and b/src/core/__pycache__/config.cpython-313.pyc differ
diff --git a/src/core/__pycache__/database.cpython-313.pyc b/src/core/__pycache__/database.cpython-313.pyc
new file mode 100644
index 0000000..d8440f3
Binary files /dev/null and b/src/core/__pycache__/database.cpython-313.pyc differ
diff --git a/src/core/__pycache__/dependencies.cpython-313.pyc b/src/core/__pycache__/dependencies.cpython-313.pyc
new file mode 100644
index 0000000..c4b01e9
Binary files /dev/null and b/src/core/__pycache__/dependencies.cpython-313.pyc differ
diff --git a/src/core/__pycache__/schemas.cpython-313.pyc b/src/core/__pycache__/schemas.cpython-313.pyc
new file mode 100644
index 0000000..ed78e67
Binary files /dev/null and b/src/core/__pycache__/schemas.cpython-313.pyc differ
diff --git a/src/core/auth.py b/src/core/auth.py
new file mode 100644
index 0000000..eab4f51
--- /dev/null
+++ b/src/core/auth.py
@@ -0,0 +1,53 @@
+import base64
+import hashlib
+import os
+import secrets
+from typing import Optional
+
+import bcrypt
+from cryptography.fernet import Fernet
+from jose import JWTError, jwt
+
+# ── Password hashing ──────────────────────────────────────────────────────────
+ALGORITHM = "HS256"
+
+# Loaded once at import time; changing SECRET_KEY invalidates all existing tokens.
+SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
+
+
+def hash_password(plain: str) -> str:
+ return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
+
+
+# ── JWT ───────────────────────────────────────────────────────────────────────
+
+def create_token(payload: dict) -> str:
+ """Encode a JWT. Caller must add an 'exp' datetime to the payload."""
+ return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def decode_token(token: str) -> Optional[dict]:
+ """Return the decoded payload, or None if the token is invalid / expired."""
+ try:
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ except JWTError:
+ return None
+
+
+# ── Symmetric encryption for AVConnect passwords ─────────────────────────────
+# Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed.
+_raw = os.environ.get("SECRET_KEY") or SECRET_KEY
+_fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest())
+_fernet = Fernet(_fernet_key)
+
+
+def encrypt_secret(value: str) -> str:
+ return _fernet.encrypt(value.encode()).decode()
+
+
+def decrypt_secret(value: str) -> str:
+ return _fernet.decrypt(value.encode()).decode()
diff --git a/src/core/config.py b/src/core/config.py
new file mode 100644
index 0000000..d6af125
--- /dev/null
+++ b/src/core/config.py
@@ -0,0 +1,3 @@
+import os
+
+MOCK_AVCONNECT: bool = os.environ.get("MOCK_AVCONNECT", "").lower() in ("1", "true", "yes")
diff --git a/src/core/database.py b/src/core/database.py
new file mode 100644
index 0000000..4a2d515
--- /dev/null
+++ b/src/core/database.py
@@ -0,0 +1,91 @@
+import os
+from datetime import datetime
+
+from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_SRC_DIR = os.path.dirname(_HERE)
+_PROJECT_ROOT = os.path.dirname(_SRC_DIR)
+_DATA_DIR = os.path.join(_PROJECT_ROOT, "data")
+
+DATABASE_URL = os.environ.get(
+ "DATABASE_URL",
+ f"sqlite:///{os.path.join(_DATA_DIR, 'gates.db')}",
+)
+
+engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class GateDB(Base):
+ __tablename__ = "gates"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, nullable=False)
+ gate_type = Column(String, nullable=False) # 'car' | 'pedestrian'
+ avconnect_macro_id = Column(String, nullable=False) # AVConnect macro ID
+ status = Column(String, default="enabled") # 'enabled' | 'disabled'
+
+
+class ApiCredential(Base):
+ __tablename__ = "api_credentials"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ username = Column(String, nullable=False)
+ password_enc = Column(String, nullable=False) # Fernet-encrypted
+ session_id = Column(String, nullable=True)
+
+
+class Keypass(Base):
+ __tablename__ = "keypasses"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ code = Column(String, unique=True, nullable=False)
+ description = Column(Text, nullable=False)
+ created_at = Column(DateTime, nullable=False)
+ expires_at = Column(DateTime, nullable=True) # NULL = never expires
+ revoked = Column(Boolean, default=False)
+ revoked_at = Column(DateTime, nullable=True)
+ allowed_gates = Column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates
+
+
+class GateAccessLog(Base):
+ __tablename__ = "gate_access_logs"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ timestamp = Column(DateTime, nullable=False)
+ keypass_id = Column(Integer, nullable=False)
+ keypass_code = Column(String, nullable=False)
+ gate_id = Column(Integer, nullable=False)
+ gate_name = Column(String, nullable=False)
+ ip_address = Column(String, nullable=True)
+ user_agent = Column(Text, nullable=True)
+ success = Column(Boolean, nullable=False)
+ error = Column(Text, nullable=True)
+
+
+class AdminUser(Base):
+ __tablename__ = "admin_users"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ username = Column(String, unique=True, nullable=False)
+ password_hash = Column(String, nullable=False)
+ role = Column(String, nullable=False, default="admin") # 'admin' | 'manager'
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
+
+def init_db() -> None:
+ os.makedirs(_DATA_DIR, exist_ok=True)
+ Base.metadata.create_all(bind=engine)
diff --git a/src/core/dependencies.py b/src/core/dependencies.py
new file mode 100644
index 0000000..22f02a8
--- /dev/null
+++ b/src/core/dependencies.py
@@ -0,0 +1,50 @@
+from datetime import datetime
+from typing import Optional
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy.orm import Session
+
+from core.auth import decode_token
+from core.database import Keypass, get_db
+
+_security = HTTPBearer()
+
+
+def require_admin(creds: HTTPAuthorizationCredentials = Depends(_security)) -> dict:
+ payload = decode_token(creds.credentials)
+ if not payload or payload.get("role") != "admin":
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin access required")
+ if payload.get("scope") != "admin":
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions")
+ return payload
+
+
+def require_manager(creds: HTTPAuthorizationCredentials = Depends(_security)) -> dict:
+ """Accepts both admins and managers."""
+ payload = decode_token(creds.credentials)
+ if not payload or payload.get("role") != "admin":
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin access required")
+ if payload.get("scope") not in ("admin", "manager"):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions")
+ return payload
+
+
+def require_keypass(
+ creds: HTTPAuthorizationCredentials = Depends(_security),
+ db: Session = Depends(get_db),
+) -> Keypass:
+ payload = decode_token(creds.credentials)
+ if not payload or payload.get("role") != "keypass":
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass token")
+
+ kp: Optional[Keypass] = db.query(Keypass).filter(
+ Keypass.id == int(payload["sub"])
+ ).first()
+ if not kp:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found")
+ if kp.revoked:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
+ if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
+ return kp
diff --git a/src/core/schemas.py b/src/core/schemas.py
new file mode 100644
index 0000000..c60731a
--- /dev/null
+++ b/src/core/schemas.py
@@ -0,0 +1,123 @@
+import json
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict
+
+from core.database import Keypass
+
+
+# ── Auth ──────────────────────────────────────────────────────────────────────
+
+class AdminLoginRequest(BaseModel):
+ username: str
+ password: str
+
+
+class KeypassLoginRequest(BaseModel):
+ code: str
+
+
+class TokenResponse(BaseModel):
+ token: str
+ token_type: str = "bearer"
+
+
+# ── Keypasses ─────────────────────────────────────────────────────────────────
+
+class KeypassCreate(BaseModel):
+ description: str
+ expires_at: Optional[datetime] = None # None = never expires
+ gate_ids: list[int] = [] # empty = all gates
+ code: Optional[str] = None # None = auto-generate
+
+
+class KeypassPatch(BaseModel):
+ description: Optional[str] = None
+ expires_at: Optional[datetime] = None # None = never expires
+ gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates
+
+
+class KeypassResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: int
+ code: str
+ description: str
+ created_at: datetime
+ expires_at: Optional[datetime]
+ revoked: bool
+ revoked_at: Optional[datetime] = None
+ allowed_gate_ids: list[int] # empty = all gates
+
+
+def keypass_to_response(kp: Keypass) -> KeypassResponse:
+ return KeypassResponse(
+ id=kp.id,
+ code=kp.code,
+ description=kp.description,
+ created_at=kp.created_at,
+ expires_at=kp.expires_at,
+ revoked=kp.revoked,
+ revoked_at=kp.revoked_at,
+ allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [],
+ )
+
+
+# ── Gates ─────────────────────────────────────────────────────────────────────
+
+class GateResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: int
+ name: str
+ gate_type: str
+ avconnect_macro_id: str
+ status: str
+
+
+class GateCreate(BaseModel):
+ name: str
+ gate_type: str # 'car' | 'pedestrian'
+ avconnect_macro_id: str
+ status: str = "enabled"
+
+
+# ── AVConnect Credentials ─────────────────────────────────────────────────────
+
+class CredentialRead(BaseModel):
+ id: int
+ username: str
+
+
+class CredentialUpsert(BaseModel):
+ username: str
+ password: str
+
+
+# ── Admin users ───────────────────────────────────────────────────────────────
+
+class AdminUserResponse(BaseModel):
+ id: int
+ username: str
+ role: str # 'admin' | 'manager'
+
+
+class AdminUserCreate(BaseModel):
+ username: str
+ password: str
+ role: str = "admin" # 'admin' | 'manager'
+
+
+# ── Statistics ────────────────────────────────────────────────────────────────
+
+class AccessLogResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: int
+ timestamp: datetime
+ keypass_id: int
+ keypass_code: str
+ gate_id: int
+ gate_name: str
+ ip_address: Optional[str]
+ user_agent: Optional[str]
+ success: bool
+ error: Optional[str]
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..2da09de
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,98 @@
+import os
+import sys
+
+import uvicorn
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+# Ensure src/ root is importable for models/services/routers
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from core.auth import hash_password
+from core.database import AdminUser, SessionLocal, init_db
+from routers.auth import router as auth_router
+from routers.keypasses import router as keypasses_router
+from routers.gates import router as gates_router
+from routers.credentials import router as credentials_router
+from routers.admins import router as admins_router
+from routers.stats import router as stats_router
+
+# ── App ───────────────────────────────────────────────────────────────────────
+app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None)
+
+_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
+app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# ── Routers (Controllers) ─────────────────────────────────────────────────────
+app.include_router(auth_router)
+app.include_router(keypasses_router)
+app.include_router(gates_router)
+app.include_router(credentials_router)
+app.include_router(admins_router)
+app.include_router(stats_router)
+
+# ── Static / frontend ─────────────────────────────────────────────────────────
+@app.get("/favicon.ico", include_in_schema=False)
+async def _serve_favicon() -> FileResponse:
+ return FileResponse(
+ os.path.join(_STATIC_DIR, "logo.svg"), media_type="image/svg+xml"
+ )
+
+
+@app.get("/", include_in_schema=False)
+async def _serve_index() -> FileResponse:
+ return FileResponse(os.path.join(_STATIC_DIR, "index.html"))
+
+
+@app.get("/admin", include_in_schema=False)
+async def _serve_admin() -> FileResponse:
+ return FileResponse(os.path.join(_STATIC_DIR, "admin.html"))
+
+
+@app.get("/sw.js", include_in_schema=False)
+async def _serve_sw() -> FileResponse:
+ return FileResponse(
+ os.path.join(_STATIC_DIR, "sw.js"), media_type="application/javascript"
+ )
+
+
+@app.get("/manifest.json", include_in_schema=False)
+async def _serve_manifest() -> FileResponse:
+ return FileResponse(
+ os.path.join(_STATIC_DIR, "manifest.json"), media_type="application/json"
+ )
+
+
+# ── Startup ───────────────────────────────────────────────────────────────────
+@app.on_event("startup")
+async def _startup() -> None:
+ init_db()
+ _seed_admin()
+
+
+def _seed_admin() -> None:
+ """Create the initial admin user from env vars if it doesn't exist yet."""
+ username = os.environ.get("ADMIN_USERNAME", "admin")
+ password = os.environ.get("ADMIN_PASSWORD")
+ if not password:
+ return
+ db = SessionLocal()
+ try:
+ if not db.query(AdminUser).filter_by(username=username).first():
+ db.add(AdminUser(username=username, password_hash=hash_password(password)))
+ db.commit()
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
diff --git a/src/models/__init__.py b/src/models/__init__.py
new file mode 100644
index 0000000..0358a52
--- /dev/null
+++ b/src/models/__init__.py
@@ -0,0 +1,9 @@
+from .credential import Credential
+from .gate import Gate
+from .status import Status
+
+__all__ = [
+ "Credential",
+ "Gate",
+ "Status"
+]
\ No newline at end of file
diff --git a/src/models/__pycache__/__init__.cpython-313.pyc b/src/models/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..3cfc8ba
Binary files /dev/null and b/src/models/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/models/__pycache__/credential.cpython-313.pyc b/src/models/__pycache__/credential.cpython-313.pyc
new file mode 100644
index 0000000..e98fce6
Binary files /dev/null and b/src/models/__pycache__/credential.cpython-313.pyc differ
diff --git a/src/models/__pycache__/gate.cpython-313.pyc b/src/models/__pycache__/gate.cpython-313.pyc
new file mode 100644
index 0000000..6f91d05
Binary files /dev/null and b/src/models/__pycache__/gate.cpython-313.pyc differ
diff --git a/src/models/__pycache__/status.cpython-313.pyc b/src/models/__pycache__/status.cpython-313.pyc
new file mode 100644
index 0000000..9f9ef83
Binary files /dev/null and b/src/models/__pycache__/status.cpython-313.pyc differ
diff --git a/src/models/credential.py b/src/models/credential.py
new file mode 100644
index 0000000..6ff61bc
--- /dev/null
+++ b/src/models/credential.py
@@ -0,0 +1,5 @@
+class Credential:
+ def __init__(self, username: str, password: str):
+ self.username = username
+ self.password = password
+ self.sessionid = None
\ No newline at end of file
diff --git a/src/models/gate.py b/src/models/gate.py
new file mode 100644
index 0000000..98669c1
--- /dev/null
+++ b/src/models/gate.py
@@ -0,0 +1,9 @@
+from .status import Status
+from .credential import Credential
+
+class Gate:
+ def __init__(self, id: str, name: str, status: Status = Status.ENABLED):
+ self.id = id
+ self.name = name
+ self.status = status if isinstance(status, Status) else Status(status)
+
diff --git a/src/models/status.py b/src/models/status.py
new file mode 100644
index 0000000..5282ab6
--- /dev/null
+++ b/src/models/status.py
@@ -0,0 +1,5 @@
+from enum import Enum
+
+class Status(Enum):
+ ENABLED = 1
+ DISABLED = 0
\ No newline at end of file
diff --git a/src/routers/__init__.py b/src/routers/__init__.py
new file mode 100644
index 0000000..9c8ddfa
--- /dev/null
+++ b/src/routers/__init__.py
@@ -0,0 +1 @@
+# routers package
diff --git a/src/routers/__pycache__/__init__.cpython-313.pyc b/src/routers/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..8a74624
Binary files /dev/null and b/src/routers/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/admins.cpython-313.pyc b/src/routers/__pycache__/admins.cpython-313.pyc
new file mode 100644
index 0000000..801015d
Binary files /dev/null and b/src/routers/__pycache__/admins.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/auth.cpython-313.pyc b/src/routers/__pycache__/auth.cpython-313.pyc
new file mode 100644
index 0000000..6f550f0
Binary files /dev/null and b/src/routers/__pycache__/auth.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/credentials.cpython-313.pyc b/src/routers/__pycache__/credentials.cpython-313.pyc
new file mode 100644
index 0000000..36e367d
Binary files /dev/null and b/src/routers/__pycache__/credentials.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/gates.cpython-313.pyc b/src/routers/__pycache__/gates.cpython-313.pyc
new file mode 100644
index 0000000..b8f8623
Binary files /dev/null and b/src/routers/__pycache__/gates.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/keypasses.cpython-313.pyc b/src/routers/__pycache__/keypasses.cpython-313.pyc
new file mode 100644
index 0000000..7ede395
Binary files /dev/null and b/src/routers/__pycache__/keypasses.cpython-313.pyc differ
diff --git a/src/routers/__pycache__/stats.cpython-313.pyc b/src/routers/__pycache__/stats.cpython-313.pyc
new file mode 100644
index 0000000..1f06886
Binary files /dev/null and b/src/routers/__pycache__/stats.cpython-313.pyc differ
diff --git a/src/routers/admins.py b/src/routers/admins.py
new file mode 100644
index 0000000..5c9c18c
--- /dev/null
+++ b/src/routers/admins.py
@@ -0,0 +1,55 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from core.auth import hash_password
+from core.database import AdminUser, get_db
+from core.dependencies import require_admin
+from core.schemas import AdminUserCreate, AdminUserResponse
+
+router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
+
+
+@router.get("", response_model=list[AdminUserResponse])
+async def list_admins(
+ db: Session = Depends(get_db), _: dict = Depends(require_admin)
+):
+ return [AdminUserResponse(id=u.id, username=u.username, role=u.role) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
+
+
+@router.post("", response_model=AdminUserResponse, status_code=201)
+async def create_admin(
+ req: AdminUserCreate,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_admin),
+):
+ username = req.username.strip()
+ if not username:
+ raise HTTPException(422, "Username cannot be empty")
+ if req.role not in ("admin", "manager"):
+ raise HTTPException(422, "role must be 'admin' or 'manager'")
+ if db.query(AdminUser).filter_by(username=username).first():
+ raise HTTPException(409, "Username already exists")
+ user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role)
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return AdminUserResponse(id=user.id, username=user.username, role=user.role)
+
+
+@router.delete("/{username}", status_code=204)
+async def delete_admin(
+ username: str,
+ db: Session = Depends(get_db),
+ caller: dict = Depends(require_admin),
+):
+ if username == caller["sub"]:
+ raise HTTPException(409, "Cannot delete your own account")
+ user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
+ if not user:
+ raise HTTPException(404, "Admin not found")
+ if db.query(AdminUser).count() <= 1:
+ raise HTTPException(409, "Cannot delete the last admin account")
+ db.delete(user)
+ db.commit()
diff --git a/src/routers/auth.py b/src/routers/auth.py
new file mode 100644
index 0000000..2459f2a
--- /dev/null
+++ b/src/routers/auth.py
@@ -0,0 +1,45 @@
+from datetime import datetime, timedelta
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from core.auth import create_token, verify_password
+from core.database import AdminUser, Keypass, get_db
+from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+@router.post("/admin", response_model=TokenResponse)
+async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
+ user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
+ if not user or not verify_password(req.password, user.password_hash):
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
+ token = create_token({
+ "sub": user.username,
+ "role": "admin",
+ "scope": user.role, # 'admin' | 'manager'
+ "exp": datetime.utcnow() + timedelta(hours=24),
+ })
+ return TokenResponse(token=token)
+
+
+@router.post("/keypass", response_model=TokenResponse)
+async def keypass_login(req: KeypassLoginRequest, db: Session = Depends(get_db)):
+ kp: Optional[Keypass] = db.query(Keypass).filter_by(
+ code=req.code.strip().upper()
+ ).first()
+ if not kp:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass")
+ if kp.revoked:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
+ if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
+ exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59)
+ token = create_token({
+ "sub": str(kp.id),
+ "role": "keypass",
+ "exp": exp,
+ })
+ return TokenResponse(token=token)
diff --git a/src/routers/credentials.py b/src/routers/credentials.py
new file mode 100644
index 0000000..d6312a4
--- /dev/null
+++ b/src/routers/credentials.py
@@ -0,0 +1,40 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from core.auth import encrypt_secret
+from core.database import ApiCredential, get_db
+from core.dependencies import require_admin
+from core.schemas import CredentialRead, CredentialUpsert
+
+router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
+
+
+@router.get("", response_model=list[CredentialRead])
+async def list_credentials(
+ db: Session = Depends(get_db), _: dict = Depends(require_admin)
+):
+ return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()]
+
+
+@router.put("", response_model=CredentialRead)
+async def upsert_credential(
+ req: CredentialUpsert,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_admin),
+):
+ cred: Optional[ApiCredential] = db.query(ApiCredential).first()
+ if cred:
+ cred.username = req.username
+ cred.password_enc = encrypt_secret(req.password)
+ cred.session_id = None # invalidate any cached session
+ else:
+ cred = ApiCredential(
+ username=req.username,
+ password_enc=encrypt_secret(req.password),
+ )
+ db.add(cred)
+ db.commit()
+ db.refresh(cred)
+ return CredentialRead(id=cred.id, username=cred.username)
diff --git a/src/routers/gates.py b/src/routers/gates.py
new file mode 100644
index 0000000..f008614
--- /dev/null
+++ b/src/routers/gates.py
@@ -0,0 +1,189 @@
+import json
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+
+from core.auth import decrypt_secret
+from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, get_db
+from core.dependencies import require_admin, require_manager, require_keypass
+from models import Credential, Gate as GateModel, Status
+from core.schemas import GateCreate, GateResponse
+from services.gates import call_open_gate
+
+router = APIRouter(tags=["gates"])
+
+
+# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
+
+@router.get("/api/admin/gates", response_model=list[GateResponse])
+async def list_gates_admin(
+ db: Session = Depends(get_db), _: dict = Depends(require_manager)
+):
+ return db.query(GateDB).order_by(GateDB.id).all()
+
+
+@router.post("/api/admin/gates", response_model=GateResponse, status_code=201)
+async def create_gate(
+ req: GateCreate,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_admin),
+):
+ if req.gate_type not in ("car", "pedestrian"):
+ raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
+ gate = GateDB(**req.model_dump())
+ db.add(gate)
+ db.commit()
+ db.refresh(gate)
+ return gate
+
+
+@router.put("/api/admin/gates/{gate_id}", response_model=GateResponse)
+async def update_gate(
+ gate_id: int,
+ req: GateCreate,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_admin),
+):
+ gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
+ if not gate:
+ raise HTTPException(404, "Gate not found")
+ for k, v in req.model_dump().items():
+ setattr(gate, k, v)
+ db.commit()
+ db.refresh(gate)
+ return gate
+
+
+@router.delete("/api/admin/gates/{gate_id}", status_code=204)
+async def delete_gate(
+ gate_id: int,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_admin),
+):
+ gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
+ if not gate:
+ raise HTTPException(404, "Gate not found")
+ db.delete(gate)
+ db.commit()
+
+
+@router.post("/api/admin/gates/{gate_id}/open")
+async def admin_open_gate(
+ gate_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ caller: dict = Depends(require_manager),
+):
+ gate_db: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
+ if not gate_db:
+ raise HTTPException(404, "Gate not found")
+ if gate_db.status != "enabled":
+ raise HTTPException(409, "Gate is disabled")
+
+ cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
+ if not cred_db:
+ raise HTTPException(503, "AVConnect credentials not configured")
+
+ credential = Credential(
+ username=cred_db.username,
+ password=decrypt_secret(cred_db.password_enc),
+ )
+ credential.sessionid = cred_db.session_id
+
+ gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=Status.ENABLED)
+ ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
+ ua = request.headers.get("User-Agent")
+
+ success, error_msg, new_sid = call_open_gate(gate, credential)
+
+ db.add(GateAccessLog(
+ timestamp=datetime.utcnow(),
+ keypass_id=0,
+ keypass_code=f"[{caller['sub']}]",
+ gate_id=gate_db.id,
+ gate_name=gate_db.name,
+ ip_address=ip,
+ user_agent=ua,
+ success=success,
+ error=error_msg,
+ ))
+
+ if new_sid and new_sid != cred_db.session_id:
+ cred_db.session_id = new_sid
+ db.commit()
+
+ if not success:
+ raise HTTPException(502, error_msg or "Gate operation failed")
+
+ return {"success": True, "gate": gate_db.name}
+
+
+# ── User-facing gate routes ───────────────────────────────────────────────────
+
+@router.get("/api/gates", response_model=list[GateResponse])
+async def list_gates(
+ db: Session = Depends(get_db), _kp: Keypass = Depends(require_keypass)
+):
+ allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
+ q = db.query(GateDB).filter(GateDB.status == "enabled")
+ if allowed is not None:
+ q = q.filter(GateDB.id.in_(allowed))
+ return q.order_by(GateDB.id).all()
+
+
+@router.post("/api/gates/{gate_id}/open")
+async def open_gate(
+ gate_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ _kp: Keypass = Depends(require_keypass),
+):
+ gate_db: Optional[GateDB] = db.query(GateDB).filter(
+ GateDB.id == gate_id, GateDB.status == "enabled"
+ ).first()
+ if not gate_db:
+ raise HTTPException(404, "Gate not found or disabled")
+
+ cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
+ if not cred_db:
+ raise HTTPException(503, "AVConnect credentials not configured")
+
+ credential = Credential(
+ username=cred_db.username,
+ password=decrypt_secret(cred_db.password_enc),
+ )
+ credential.sessionid = cred_db.session_id
+
+ gate_status = Status.ENABLED if gate_db.status == "enabled" else Status.DISABLED
+ gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=gate_status)
+ ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
+ ua = request.headers.get("User-Agent")
+
+ allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
+ if allowed is not None and gate_id not in allowed:
+ raise HTTPException(403, "This keypass does not have access to this gate")
+
+ success, error_msg, new_sid = call_open_gate(gate, credential)
+
+ db.add(GateAccessLog(
+ timestamp=datetime.utcnow(),
+ keypass_id=_kp.id,
+ keypass_code=_kp.code,
+ gate_id=gate_db.id,
+ gate_name=gate_db.name,
+ ip_address=ip,
+ user_agent=ua,
+ success=success,
+ error=error_msg,
+ ))
+
+ if new_sid and new_sid != cred_db.session_id:
+ cred_db.session_id = new_sid
+ db.commit()
+
+ if not success:
+ raise HTTPException(502, error_msg or "Gate operation failed")
+
+ return {"success": True, "gate": gate_db.name}
diff --git a/src/routers/keypasses.py b/src/routers/keypasses.py
new file mode 100644
index 0000000..b6d8777
--- /dev/null
+++ b/src/routers/keypasses.py
@@ -0,0 +1,89 @@
+import json
+import secrets
+import string
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from core.database import Keypass, get_db
+from core.dependencies import require_manager
+from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_to_response
+
+router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
+
+
+def _generate_code(length: int = 12) -> str:
+ alphabet = string.ascii_uppercase + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+@router.get("", response_model=list[KeypassResponse])
+async def list_keypasses(
+ db: Session = Depends(get_db), _: dict = Depends(require_manager)
+):
+ return [keypass_to_response(kp) for kp in db.query(Keypass).order_by(Keypass.created_at.desc()).all()]
+
+
+@router.post("", response_model=KeypassResponse, status_code=201)
+async def create_keypass(
+ req: KeypassCreate,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_manager),
+):
+ code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code()
+ if db.query(Keypass).filter(Keypass.code == code).first():
+ raise HTTPException(409, "A keypass with this code already exists")
+ kp = Keypass(
+ code=code,
+ description=req.description,
+ created_at=datetime.utcnow(),
+ expires_at=req.expires_at,
+ revoked=False,
+ allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
+ )
+ db.add(kp)
+ db.commit()
+ db.refresh(kp)
+ return keypass_to_response(kp)
+
+
+@router.patch("/{kp_id}", response_model=KeypassResponse)
+async def update_keypass(
+ kp_id: int,
+ req: KeypassPatch,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_manager),
+):
+ kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
+ if not kp:
+ raise HTTPException(404, "Keypass not found")
+ if kp.revoked:
+ raise HTTPException(409, "Revoked keypasses cannot be edited")
+ if req.description is not None:
+ kp.description = req.description.strip()
+ kp.expires_at = req.expires_at
+ if req.gate_ids is not None:
+ kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
+ db.commit()
+ db.refresh(kp)
+ return keypass_to_response(kp)
+
+
+@router.delete("/{kp_id}", status_code=204)
+async def revoke_keypass(
+ kp_id: int,
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_manager),
+):
+ kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
+ if not kp:
+ raise HTTPException(404, "Keypass not found")
+ if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
+ raise HTTPException(409, "Expired keypasses cannot be revoked")
+ if kp.revoked:
+ raise HTTPException(409, "Keypass is already revoked")
+ kp.revoked = True
+ kp.revoked_at = datetime.utcnow()
+ db.commit()
diff --git a/src/routers/stats.py b/src/routers/stats.py
new file mode 100644
index 0000000..f0b04f3
--- /dev/null
+++ b/src/routers/stats.py
@@ -0,0 +1,21 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from core.database import GateAccessLog, get_db
+from core.dependencies import require_manager
+from core.schemas import AccessLogResponse
+
+router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
+
+
+@router.get("", response_model=list[AccessLogResponse])
+async def get_stats(
+ db: Session = Depends(get_db),
+ _: dict = Depends(require_manager),
+):
+ return (
+ db.query(GateAccessLog)
+ .order_by(GateAccessLog.timestamp.desc())
+ .limit(500)
+ .all()
+ )
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..aafca4e
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1,9 @@
+from .avconnect import AVConnectAPI
+from .gates import GatesService, OpenResult, call_open_gate
+
+__all__ = [
+ "AVConnectAPI",
+ "GatesService",
+ "OpenResult",
+ "call_open_gate",
+]
\ No newline at end of file
diff --git a/src/services/__pycache__/__init__.cpython-313.pyc b/src/services/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..73df0bb
Binary files /dev/null and b/src/services/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/services/__pycache__/avconnect.cpython-313.pyc b/src/services/__pycache__/avconnect.cpython-313.pyc
new file mode 100644
index 0000000..0ac08da
Binary files /dev/null and b/src/services/__pycache__/avconnect.cpython-313.pyc differ
diff --git a/src/services/__pycache__/gates.cpython-313.pyc b/src/services/__pycache__/gates.cpython-313.pyc
new file mode 100644
index 0000000..34690d0
Binary files /dev/null and b/src/services/__pycache__/gates.cpython-313.pyc differ
diff --git a/src/services/avconnect.py b/src/services/avconnect.py
new file mode 100644
index 0000000..108d48d
--- /dev/null
+++ b/src/services/avconnect.py
@@ -0,0 +1,53 @@
+import requests
+from fake_useragent import UserAgent
+from models import Credential
+
+class AVConnectAPI:
+ _BASE_URL = "https://www.avconnect.it"
+
+ def __init__(self, credentials: Credential):
+ self._ua = UserAgent(browsers=["Chrome Mobile"], os=["Android"], platforms=["mobile"]).random
+ self._credentials = credentials
+ self._session = requests.Session()
+ self._authenticated = False
+
+ if credentials.sessionid:
+ self._session.cookies.set("PHPSESSID", credentials.sessionid)
+ self._authenticated = True
+
+ def _authenticate(self) -> bool:
+ login_url = f"{self._BASE_URL}/loginone.php"
+ headers = {
+ "User-Agent": self._ua,
+ "Content-Type": "application/x-www-form-urlencoded"
+ }
+ payload = f"userid={self._credentials.username}&password={self._credentials.password}&entra=Login"
+ response = self._session.post(login_url, data=payload, headers=headers)
+ if response.ok and "PHPSESSID" in self._session.cookies:
+ self._authenticated = True
+ print("Authenticated")
+ return True
+ return False
+
+ def _check_sessionid(self) -> bool:
+ if not self._authenticated or not self._credentials.sessionid:
+ return False
+ exec_url = f"{self._BASE_URL}/exemacrocom.php"
+ headers = {
+ "User-Agent": self._ua,
+ }
+ response = self._session.get(exec_url, headers=headers)
+ print(response.ok)
+ return response.ok
+
+ def exec_gate_macro(self, id_macro) -> bool:
+ if (not self._authenticated or not self._check_sessionid()) and not self._authenticate():
+ raise Exception("Authentication failed.")
+ exec_url = f"{self._BASE_URL}/exemacrocom.php"
+ headers = {
+ "User-Agent": self._ua,
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
+ }
+ payload = f"idmacrocom={id_macro}&nome=16"
+ response = self._session.post(exec_url, data=payload, headers=headers)
+ return response.ok
\ No newline at end of file
diff --git a/src/services/gates.py b/src/services/gates.py
new file mode 100644
index 0000000..e3b6e36
--- /dev/null
+++ b/src/services/gates.py
@@ -0,0 +1,38 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from models import Credential, Status, Gate
+from .avconnect import AVConnectAPI
+
+
+@dataclass
+class OpenResult:
+ success: bool
+ error: Optional[str] = None
+ new_session_id: Optional[str] = None
+
+
+class GatesService:
+ def open_gate(self, gate: Gate, credentials: Credential) -> OpenResult:
+ if gate.status == Status.DISABLED:
+ return OpenResult(success=False, error="Gate is disabled")
+ try:
+ api = AVConnectAPI(credentials)
+ ok = api.exec_gate_macro(gate.id)
+ new_sid = api._session.cookies.get("PHPSESSID")
+ if not ok:
+ return OpenResult(success=False, error="Gate did not confirm open", new_session_id=new_sid)
+ return OpenResult(success=True, new_session_id=new_sid)
+ except Exception as e:
+ return OpenResult(success=False, error=str(e))
+
+
+def call_open_gate(gate: Gate, credentials: Credential) -> tuple[bool, Optional[str], Optional[str]]:
+ """Attempt to open a gate. Returns (success, error_msg, new_session_id).
+ Respects the MOCK_AVCONNECT environment variable.
+ """
+ from core.config import MOCK_AVCONNECT
+ if MOCK_AVCONNECT:
+ return True, None, None
+ result = GatesService().open_gate(gate, credentials)
+ return result.success, result.error, result.new_session_id
diff --git a/src/static/admin.html b/src/static/admin.html
new file mode 100644
index 0000000..e6d4c82
--- /dev/null
+++ b/src/static/admin.html
@@ -0,0 +1,384 @@
+
+
+
+
+
+
+ Lagomare Gates – Admin
+
+
+
+
+
+
+
+
+
+
🔐
+
Admin Panel
+
Lagomare Gates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Code |
+ Description |
+ Gates |
+ Expires |
+ Status |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Name |
+ Type |
+ AVConnect Macro ID |
+ Status |
+ |
+
+
+
+
+
+
+
+
+
+
AVConnect Credentials
+
+
+
+
+
+
+
+
+
+
+ | Time |
+ Keypass |
+ Gate |
+ IP |
+ User Agent |
+ Result |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/static/admin.js b/src/static/admin.js
new file mode 100644
index 0000000..2efdbb1
--- /dev/null
+++ b/src/static/admin.js
@@ -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 = '| No keypasses yet |
';
+ 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 = 'Revoked';
+ else if (expMs < now) badge = 'Expired';
+ else badge = 'Active';
+
+ const gatesCell = kp.allowed_gate_ids && kp.allowed_gate_ids.length
+ ? `${kp.allowed_gate_ids.length} gate${kp.allowed_gate_ids.length > 1 ? "s" : ""}`
+ : 'All';
+
+ const expiresCell = kp.expires_at
+ ? `${fmtDate(kp.expires_at)}`
+ : 'Never';
+
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${esc(kp.code)} |
+ ${esc(kp.description)} |
+ ${gatesCell} |
+ ${expiresCell} |
+ ${badge} |
+
+ ${!kp.revoked ? `` : ""}
+ ${!kp.revoked && expMs >= now ? `` : ""}
+ | `;
+ 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
+ ? ""
+ : 'No gates configured yet';
+ 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 = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`;
+ 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
+ ? ""
+ : 'No gates configured yet';
+ 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 = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`;
+ 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 = '| No gates yet |
';
+ return;
+ }
+ for (const g of rows) {
+ const badge = g.status === "enabled"
+ ? 'Enabled'
+ : 'Disabled';
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${g.id} |
+ ${esc(g.name)} |
+ ${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"} |
+ ${esc(g.avconnect_macro_id)} |
+ ${badge} |
+
+ ${g.status === 'enabled' ? `` : ''}
+ ${isAdmin ? `
+ ` : ''}
+ | `;
+ 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 = '| No access logs yet |
';
+ return;
+ }
+ for (const r of rows) {
+ const badge = r.success
+ ? 'OK'
+ : `Fail`;
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${fmtDate(r.timestamp.replace(' ', 'T'))} |
+ ${esc(r.keypass_code)} |
+ ${esc(r.gate_name)} |
+ ${esc(r.ip_address || '–')} |
+ ${esc(r.user_agent || '–')} |
+ ${badge} | `;
+ 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 = '| No admins |
';
+ return;
+ }
+ const me = _tokenPayload().sub;
+ for (const u of rows) {
+ const roleBadge = u.role === "admin"
+ ? 'admin'
+ : 'manager';
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${esc(u.username)}${u.username === me ? ' you' : ""} ${roleBadge} |
+
+ ${u.username !== me ? `` : ""}
+ | `;
+ 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, """);
+}
+
+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();
+ }
+})();
diff --git a/src/static/app.js b/src/static/app.js
new file mode 100644
index 0000000..5b35fa7
--- /dev/null
+++ b/src/static/app.js
@@ -0,0 +1,168 @@
+/* app.js – Lagomare Gates frontend */
+
+// ── Token helpers ─────────────────────────────────────────────────────────────
+const TOKEN_KEY = "lg_keypass_token";
+
+function saveToken(t) { localStorage.setItem(TOKEN_KEY, t); }
+function clearToken() { localStorage.removeItem(TOKEN_KEY); }
+function getToken() { return localStorage.getItem(TOKEN_KEY); }
+
+function tokenValid(t) {
+ if (!t) return false;
+ try {
+ const payload = JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
+ return payload.exp * 1000 > Date.now();
+ } catch { return false; }
+}
+
+// ── API helper ────────────────────────────────────────────────────────────────
+async function apiFetch(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 – please log in again.");
+ }
+ if (!res.ok) {
+ const json = await res.json().catch(() => null);
+ throw new Error((json && json.detail) || `Error ${res.status}`);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+}
+
+// ── Views ─────────────────────────────────────────────────────────────────────
+function showLogin() {
+ document.getElementById("login-view").classList.remove("hidden");
+ document.getElementById("gates-view").classList.add("hidden");
+}
+
+function showGatesView() {
+ document.getElementById("login-view").classList.add("hidden");
+ document.getElementById("gates-view").classList.remove("hidden");
+}
+
+// ── Gate rendering ────────────────────────────────────────────────────────────
+function renderGates(gates) {
+ const grid = document.getElementById("gates-grid");
+ const loading = document.getElementById("loading-gates");
+ grid.innerHTML = "";
+
+ if (!gates.length) {
+ loading.textContent = "No gates configured.";
+ loading.classList.remove("hidden");
+ grid.classList.add("hidden");
+ return;
+ }
+ loading.classList.add("hidden");
+ grid.classList.remove("hidden");
+
+ for (const gate of gates) {
+ const icon = gate.gate_type === "car" ? "🚘" : "🚶";
+ const label = gate.gate_type === "car" ? "Car" : "Pedestrian";
+ const btn = document.createElement("button");
+ btn.className = `gate-btn ${gate.gate_type}`;
+ btn.dataset.gateId = gate.id;
+ btn.innerHTML = `${icon}${gate.name}`;
+ btn.addEventListener("click", () => handleOpenGate(btn, gate.id));
+ grid.appendChild(btn);
+ }
+}
+
+async function loadGates() {
+ try {
+ const gates = await apiFetch("GET", "/api/gates");
+ renderGates(gates);
+ } catch (e) {
+ document.getElementById("loading-gates").textContent = e.message;
+ document.getElementById("loading-gates").classList.remove("hidden");
+ }
+}
+
+// ── Open gate action ──────────────────────────────────────────────────────────
+async function handleOpenGate(btn, gateId) {
+ btn.disabled = true;
+ btn.classList.add("loading");
+ btn.classList.remove("ok", "fail");
+
+ try {
+ await apiFetch("POST", `/api/gates/${encodeURIComponent(gateId)}/open`);
+ btn.classList.remove("loading");
+ btn.classList.add("ok");
+ showToast("Gate opened ✓", false);
+ setTimeout(() => btn.classList.remove("ok"), 2000);
+ } catch (e) {
+ btn.classList.remove("loading");
+ btn.classList.add("fail");
+ showToast(e.message, true);
+ setTimeout(() => btn.classList.remove("fail"), 2000);
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+// ── Toast ─────────────────────────────────────────────────────────────────────
+let _toastTimer;
+function showToast(msg, isError = false) {
+ const el = document.getElementById("toast");
+ clearTimeout(_toastTimer);
+ el.textContent = msg;
+ el.className = `toast ${isError ? "error" : "success"}`;
+ _toastTimer = setTimeout(() => el.classList.add("fade"), 2600);
+ setTimeout(() => { el.className = "toast hidden"; }, 3000);
+}
+
+// ── Login form ────────────────────────────────────────────────────────────────
+document.getElementById("login-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const code = document.getElementById("keypass-input").value.trim();
+ if (!code) return;
+
+ const btn = document.getElementById("login-btn");
+ const errEl = document.getElementById("login-error");
+ btn.disabled = true;
+ errEl.classList.add("hidden");
+
+ try {
+ const data = await apiFetch("POST", "/api/auth/keypass", { code });
+ saveToken(data.token);
+ document.getElementById("keypass-input").value = "";
+ showGatesView();
+ loadGates();
+ } catch (e) {
+ errEl.textContent = e.message;
+ errEl.classList.remove("hidden");
+ } finally {
+ btn.disabled = false;
+ }
+});
+
+// ── Logout ────────────────────────────────────────────────────────────────────
+document.getElementById("logout-btn").addEventListener("click", () => {
+ clearToken();
+ showLogin();
+});
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+(function init() {
+ const t = getToken();
+ if (tokenValid(t)) {
+ showGatesView();
+ loadGates();
+ } else {
+ clearToken();
+ showLogin();
+ }
+})();
+
+// ── Service worker registration ───────────────────────────────────────────────
+if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
+}
diff --git a/src/static/index.html b/src/static/index.html
new file mode 100644
index 0000000..752ee10
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+ Lagomare Gates
+
+
+
+
+
+
+
+
+
+
+
+

+
Lagomare Gates
+
Enter your keypass to continue
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/static/logo.svg b/src/static/logo.svg
new file mode 100644
index 0000000..7a3f970
--- /dev/null
+++ b/src/static/logo.svg
@@ -0,0 +1,53 @@
+
+
+
+
diff --git a/src/static/manifest.json b/src/static/manifest.json
new file mode 100644
index 0000000..04185f0
--- /dev/null
+++ b/src/static/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "Lagomare Gates",
+ "short_name": "Lagomare Gates",
+ "description": "Gates control panel for Lagomare residential complex",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#0f0f1a",
+ "theme_color": "#0f0f1a",
+ "icons": [
+ {
+ "src": "/static/logo.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/src/static/style.css b/src/static/style.css
new file mode 100644
index 0000000..fec7c13
--- /dev/null
+++ b/src/static/style.css
@@ -0,0 +1,191 @@
+/* ── Reset & base ──────────────────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #0f0f1a;
+ --surface: #1c1c2e;
+ --surface2: #252540;
+ --border: #2e2e50;
+ --primary: #4f8ef7;
+ --primary-dk: #3a72d6;
+ --green: #27ae6e;
+ --green-dk: #1e9057;
+ --red: #e05260;
+ --yellow: #f0a843;
+ --text: #e2e2f0;
+ --text-muted: #8888aa;
+ --radius: 12px;
+ --shadow: 0 4px 24px rgba(0,0,0,.45);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100dvh;
+ -webkit-font-smoothing: antialiased;
+}
+
+.hidden { display: none !important; }
+
+/* ── Layout helpers ────────────────────────────────────────────────────────── */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 2rem;
+ box-shadow: var(--shadow);
+}
+
+/* ── Form elements ─────────────────────────────────────────────────────────── */
+input, select, textarea {
+ width: 100%;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 1rem;
+ padding: .65rem 1rem;
+ outline: none;
+ transition: border-color .2s;
+}
+input:focus, select:focus, textarea:focus {
+ border-color: var(--primary);
+}
+label {
+ display: block;
+ font-size: .85rem;
+ color: var(--text-muted);
+ margin-bottom: .35rem;
+}
+.field { margin-bottom: 1rem; }
+
+/* ── Buttons ───────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: .5rem;
+ font-size: .95rem;
+ font-weight: 600;
+ border: none;
+ border-radius: 8px;
+ padding: .65rem 1.4rem;
+ cursor: pointer;
+ transition: opacity .15s, transform .1s;
+ white-space: nowrap;
+}
+.btn:active { transform: scale(.97); }
+.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
+
+.btn-primary { background: var(--primary); color: #fff; }
+.btn-primary:not(:disabled):hover { background: var(--primary-dk); }
+.btn-danger { background: var(--red); color: #fff; }
+.btn-danger:not(:disabled):hover { opacity: .85; }
+.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
+.btn-ghost:not(:disabled):hover { background: var(--border); }
+.btn-full { width: 100%; }
+
+/* ── Gate buttons ──────────────────────────────────────────────────────────── */
+.gate-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: .4rem;
+ padding: 1.25rem .75rem;
+ border: none;
+ border-radius: var(--radius);
+ font-size: .9rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform .12s, box-shadow .12s, opacity .15s;
+ min-height: 96px;
+ position: relative;
+ overflow: hidden;
+}
+.gate-btn .icon { font-size: 1.8rem; line-height: 1; }
+.gate-btn.car { background: var(--primary); color: #fff; }
+.gate-btn.pedestrian { background: var(--green); color: #fff; }
+.gate-btn:not(:disabled):active { transform: scale(.94); }
+.gate-btn:disabled { opacity: .55; cursor: not-allowed; }
+
+.gate-btn.loading::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: rgba(255,255,255,.15);
+ animation: pulse 1s infinite;
+}
+.gate-btn.ok { box-shadow: 0 0 0 3px #27ae6e; }
+.gate-btn.fail { box-shadow: 0 0 0 3px var(--red); }
+
+@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
+
+/* ── Toast ─────────────────────────────────────────────────────────────────── */
+.toast {
+ position: fixed;
+ bottom: 1.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: .75rem 1.5rem;
+ font-size: .9rem;
+ font-weight: 600;
+ box-shadow: var(--shadow);
+ z-index: 9999;
+ transition: opacity .3s;
+ pointer-events: none;
+}
+.toast.success { border-color: var(--green); color: var(--green); }
+.toast.error { border-color: var(--red); color: var(--red); }
+.toast.fade { opacity: 0; }
+
+/* ── Tables ────────────────────────────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; }
+table { width: 100%; border-collapse: collapse; font-size: .9rem; }
+th { background: var(--surface2); color: var(--text-muted); font-weight: 600;
+ text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
+td { padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
+tr:last-child td { border-bottom: none; }
+
+/* ── Badges ────────────────────────────────────────────────────────────────── */
+.badge {
+ display: inline-block;
+ font-size: .75rem;
+ font-weight: 700;
+ border-radius: 99px;
+ padding: .2rem .7rem;
+}
+.badge-green { background: rgba(39,174,110,.2); color: var(--green); }
+.badge-red { background: rgba(224,82,96,.2); color: var(--red); }
+.badge-yellow { background: rgba(240,168,67,.2); color: var(--yellow); }
+.badge-muted { background: var(--surface2); color: var(--text-muted); }
+
+/* ── Modal ─────────────────────────────────────────────────────────────────── */
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+.modal {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.75rem;
+ width: 100%;
+ max-width: 440px;
+ box-shadow: var(--shadow);
+}
+.modal h3 { margin-bottom: 1.25rem; font-size: 1.1rem; }
+.modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.25rem; }
+
+/* ── Error text ────────────────────────────────────────────────────────────── */
+.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
diff --git a/src/static/sw.js b/src/static/sw.js
new file mode 100644
index 0000000..9c52ba4
--- /dev/null
+++ b/src/static/sw.js
@@ -0,0 +1,26 @@
+/* Service worker – Lagomare Gates */
+const CACHE = "lagomare-gates-v1.1";
+const PRECACHE = ["/", "/static/style.css", "/static/app.js", "/static/logo.svg", "/manifest.json"];
+
+self.addEventListener("install", event => {
+ event.waitUntil(
+ caches.open(CACHE).then(c => c.addAll(PRECACHE)).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener("activate", event => {
+ event.waitUntil(
+ caches.keys().then(keys =>
+ Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
+ ).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener("fetch", event => {
+ // Let API calls always go to the network
+ if (event.request.url.includes("/api/")) return;
+
+ event.respondWith(
+ caches.match(event.request).then(cached => cached || fetch(event.request))
+ );
+});