First commit

This commit is contained in:
Ettore
2026-05-06 01:51:22 +02:00
commit 78fca8ebc2
56 changed files with 2584 additions and 0 deletions

12
.env.example Normal file
View File

@@ -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

9
requirements.txt Normal file
View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
src/core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

53
src/core/auth.py Normal file
View File

@@ -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()

3
src/core/config.py Normal file
View File

@@ -0,0 +1,3 @@
import os
MOCK_AVCONNECT: bool = os.environ.get("MOCK_AVCONNECT", "").lower() in ("1", "true", "yes")

91
src/core/database.py Normal file
View File

@@ -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)

50
src/core/dependencies.py Normal file
View File

@@ -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

123
src/core/schemas.py Normal file
View File

@@ -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]

98
src/main.py Normal file
View File

@@ -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)

9
src/models/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .credential import Credential
from .gate import Gate
from .status import Status
__all__ = [
"Credential",
"Gate",
"Status"
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

5
src/models/credential.py Normal file
View File

@@ -0,0 +1,5 @@
class Credential:
def __init__(self, username: str, password: str):
self.username = username
self.password = password
self.sessionid = None

9
src/models/gate.py Normal file
View File

@@ -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)

5
src/models/status.py Normal file
View File

@@ -0,0 +1,5 @@
from enum import Enum
class Status(Enum):
ENABLED = 1
DISABLED = 0

1
src/routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# routers package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

55
src/routers/admins.py Normal file
View File

@@ -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()

45
src/routers/auth.py Normal file
View File

@@ -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)

View File

@@ -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)

189
src/routers/gates.py Normal file
View File

@@ -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}

89
src/routers/keypasses.py Normal file
View File

@@ -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()

21
src/routers/stats.py Normal file
View File

@@ -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()
)

9
src/services/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .avconnect import AVConnectAPI
from .gates import GatesService, OpenResult, call_open_gate
__all__ = [
"AVConnectAPI",
"GatesService",
"OpenResult",
"call_open_gate",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

53
src/services/avconnect.py Normal file
View File

@@ -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

38
src/services/gates.py Normal file
View File

@@ -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

384
src/static/admin.html Normal file
View File

@@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f0f1a" />
<title>Lagomare Gates Admin</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
<link rel="stylesheet" href="/static/style.css" />
<style>
/* ── Admin login ────────────────────────────────────────────────────── */
#login-view {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
#login-view h1 { font-size: 1.5rem; font-weight: 800; margin-bottom: .25rem; }
#login-view .sub { color: var(--text-muted); font-size: .9rem; margin-bottom: 2rem; }
#login-view .card { width: 100%; max-width: 360px; }
/* ── Admin shell ────────────────────────────────────────────────────── */
#admin-view {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.app-header h2 { font-size: 1.05rem; font-weight: 800; }
/* ── Tabs ───────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-muted);
cursor: pointer;
font-size: .9rem;
font-weight: 600;
padding: .85rem 1.5rem;
white-space: nowrap;
transition: color .15s, border-color .15s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
/* ── Tab panes ──────────────────────────────────────────────────────── */
.tab-content { flex: 1; padding: 1.5rem; max-width: 960px; margin: 0 auto; width: 100%; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* ── Section header ─────────────────────────────────────────────────── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-header h3 { font-size: 1rem; font-weight: 700; }
</style>
</head>
<body>
<!-- ── Admin login ─────────────────────────────────────────────────────── -->
<div id="login-view">
<div style="font-size:2rem;margin-bottom:.5rem">🔐</div>
<h1>Admin Panel</h1>
<p class="sub">Lagomare Gates</p>
<div class="card">
<form id="login-form">
<div class="field">
<label for="admin-username">Username</label>
<input id="admin-username" type="text" autocomplete="username" />
</div>
<div class="field">
<label for="admin-password">Password</label>
<input id="admin-password" type="password" autocomplete="current-password" />
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
</form>
</div>
</div>
<!-- ── Admin shell ─────────────────────────────────────────────────────── -->
<div id="admin-view" class="hidden">
<header class="app-header">
<h2>⚙️ Admin Lagomare Gates</h2>
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Logout</button>
</header>
<nav class="tabs">
<button class="tab-btn active" data-tab="keypasses">Keypasses</button>
<button class="tab-btn" data-tab="gates">Gates</button>
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button>
<button class="tab-btn" data-tab="stats">Statistics</button>
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
</nav>
<div class="tab-content">
<!-- ── Keypasses pane ─────────────────────────────────────────────── -->
<div id="tab-keypasses" class="tab-pane active">
<div class="section-header">
<h3>Keypasses</h3>
<button id="btn-new-keypass" class="btn btn-primary">+ New Keypass</button>
</div>
<div class="table-wrap card" style="padding:0">
<table id="keypasses-table">
<thead>
<tr>
<th>Code</th>
<th>Description</th>
<th>Gates</th>
<th>Expires</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="keypasses-body"></tbody>
</table>
</div>
</div>
<!-- ── Gates pane ─────────────────────────────────────────────────── -->
<div id="tab-gates" class="tab-pane">
<div class="section-header">
<h3>Gates</h3>
<button id="btn-new-gate" class="btn btn-primary admin-only">+ Add Gate</button>
</div>
<div class="table-wrap card" style="padding:0">
<table id="gates-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>AVConnect Macro ID</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="gates-body"></tbody>
</table>
</div>
</div>
<!-- ── Credentials pane ───────────────────────────────────────────── -->
<div id="tab-credentials" class="tab-pane">
<h3 style="margin-bottom:1rem">AVConnect Credentials</h3>
<div class="card" style="max-width:440px">
<form id="credentials-form">
<div class="field">
<label for="cred-username">Username</label>
<input id="cred-username" type="text" autocomplete="off" />
</div>
<div class="field">
<label for="cred-password">Password</label>
<input id="cred-password" type="password" autocomplete="new-password"
placeholder="Leave empty to keep current" />
</div>
<p id="cred-error" class="error-msg hidden"></p>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
<!-- ── Statistics pane ───────────────────────────────────────────── -->
<div id="tab-stats" class="tab-pane">
<div class="section-header">
<h3>Gate Access Log</h3>
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
</div>
<div class="table-wrap card" style="padding:0">
<table id="stats-table">
<thead>
<tr>
<th>Time</th>
<th>Keypass</th>
<th>Gate</th>
<th>IP</th>
<th>User Agent</th>
<th>Result</th>
</tr>
</thead>
<tbody id="stats-body"></tbody>
</table>
</div>
</div>
<!-- ── Admins pane ──────────────────────────────────────────────────── -->
<div id="tab-admins" class="tab-pane">
<div class="section-header">
<h3>Admin Users</h3>
<button id="btn-new-admin" class="btn btn-primary">+ Add Admin</button>
</div>
<div class="table-wrap card" style="padding:0">
<table id="admins-table">
<thead>
<tr>
<th>Username</th>
<th></th>
</tr>
</thead>
<tbody id="admins-body"></tbody>
</table>
</div>
</div>
</div><!-- /.tab-content -->
</div><!-- /#admin-view -->
<!-- ── Keypass modal ───────────────────────────────────────────────────── -->
<div id="keypass-modal" class="modal-backdrop hidden">
<div class="modal">
<h3>New Keypass</h3>
<form id="keypass-form">
<div class="field">
<label for="kp-desc">Description</label>
<input id="kp-desc" type="text" placeholder="e.g. Guests June 2026" required />
</div>
<div class="field">
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
spellcheck="false" placeholder="Auto-generated"
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="32" />
</div>
<div class="field" id="kp-expires-field">
<label for="kp-expires">Expiry date &amp; time</label>
<input id="kp-expires" type="datetime-local" />
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-top:.6rem">
<input type="checkbox" id="kp-never-expires" style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Never expires</span>
</label>
</div>
<div class="field">
<label style="margin-bottom:.5rem">Allowed gates</label>
<div id="kp-gates-container" style="display:flex;flex-direction:column;gap:.3rem;max-height:180px;overflow-y:auto">
<label id="kp-all-gates-row" style="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">
<input type="checkbox" id="kp-all-gates" checked style="width:1rem;height:1rem;flex-shrink:0" />
<span>All gates</span>
</label>
<div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div>
</div>
<p id="kp-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- ── Keypass edit modal ────────────────────────────────────────────────── -->
<div id="kp-edit-modal" class="modal-backdrop hidden">
<div class="modal">
<h3>Edit Keypass</h3>
<form id="kp-edit-form">
<input type="hidden" id="kp-edit-id" />
<div class="field">
<label for="kp-edit-desc">Description</label>
<input id="kp-edit-desc" type="text" required />
</div>
<div class="field">
<label for="kp-edit-expires">Expiry date &amp; time</label>
<input id="kp-edit-expires" type="datetime-local" />
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-top:.6rem">
<input type="checkbox" id="kp-edit-never" style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Never expires</span>
</label>
</div>
<div class="field">
<label style="margin-bottom:.5rem">Allowed gates</label>
<div id="kp-edit-gates-container" style="display:flex;flex-direction:column;gap:.3rem;max-height:180px;overflow-y:auto">
<label id="kp-edit-all-gates-row" style="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">
<input type="checkbox" id="kp-edit-all-gates" style="width:1rem;height:1rem;flex-shrink:0" />
<span>All gates</span>
</label>
<div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div>
</div>
<p id="kp-edit-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- ── Gate modal ──────────────────────────────────────────────────────── -->
<div id="gate-modal" class="modal-backdrop hidden">
<div class="modal">
<h3 id="gate-modal-title">Add Gate</h3>
<form id="gate-form">
<input type="hidden" id="gate-edit-id" />
<div class="field">
<label for="gate-name">Name</label>
<input id="gate-name" type="text" placeholder="e.g. Main entrance Car" required />
</div>
<div class="field">
<label for="gate-type">Type</label>
<select id="gate-type">
<option value="car">Car</option>
<option value="pedestrian">Pedestrian</option>
</select>
</div>
<div class="field">
<label for="gate-avconnect-macro-id">AVConnect Macro ID</label>
<input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" required />
</div>
<div class="field">
<label for="gate-status">Status</label>
<select id="gate-status">
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
</div>
<p id="gate-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="gate-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- ── Admin user modal ──────────────────────────────────────────────────── -->
<div id="admin-modal" class="modal-backdrop hidden">
<div class="modal">
<h3>Add Admin</h3>
<form id="admin-form">
<div class="field">
<label for="admin-new-username">Username</label>
<input id="admin-new-username" type="text" autocomplete="off" required />
</div>
<div class="field">
<label for="admin-new-password">Password</label>
<input id="admin-new-password" type="password" autocomplete="new-password" required />
</div>
<div class="field">
<label for="admin-new-role">Role</label>
<select id="admin-new-role">
<option value="admin">Admin (full access)</option>
<option value="manager">Manager (keypasses only)</option>
</select>
</div>
<p id="admin-modal-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="admin-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div>
<script src="/static/admin.js"></script>
</body>
</html>

601
src/static/admin.js Normal file
View File

@@ -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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
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 = '<span class="badge badge-red">Revoked</span>';
else if (expMs < now) badge = '<span class="badge badge-muted">Expired</span>';
else badge = '<span class="badge badge-green">Active</span>';
const gatesCell = kp.allowed_gate_ids && kp.allowed_gate_ids.length
? `${kp.allowed_gate_ids.length} gate${kp.allowed_gate_ids.length > 1 ? "s" : ""}`
: '<span style="color:var(--text-muted)">All</span>';
const expiresCell = kp.expires_at
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
: '<span style="color:var(--text-muted)">Never</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
<td>${esc(kp.description)}</td>
<td>${gatesCell}</td>
<td>${expiresCell}</td>
<td>${badge}</td>
<td style="text-align:right">
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-kp-id="${kp.id}">Revoke</button>` : ""}
</td>`;
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
? ""
: '<span style="color:var(--text-muted);font-size:.85em;padding:.25rem .75rem">No gates configured yet</span>';
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 = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
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
? ""
: '<span style="color:var(--text-muted);font-size:.85em;padding:.25rem .75rem">No gates configured yet</span>';
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 = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`;
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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No gates yet</td></tr>';
return;
}
for (const g of rows) {
const badge = g.status === "enabled"
? '<span class="badge badge-green">Enabled</span>'
: '<span class="badge badge-muted">Disabled</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${g.id}</td>
<td>${esc(g.name)}</td>
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
<td>${badge}</td>
<td style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
data-open-id="${g.id}">Open</button>` : ''}
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-del-id="${g.id}">Delete</button>` : ''}
</td>`;
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 = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No access logs yet</td></tr>';
return;
}
for (const r of rows) {
const badge = r.success
? '<span class="badge badge-green">OK</span>'
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
const tr = document.createElement("tr");
tr.innerHTML = `
<td style="white-space:nowrap">${fmtDate(r.timestamp.replace(' ', 'T'))}</td>
<td><code style="font-size:.85em">${esc(r.keypass_code)}</code></td>
<td>${esc(r.gate_name)}</td>
<td style="font-size:.85em;font-family:monospace">${esc(r.ip_address || '')}</td>
<td style="font-size:.8em;color:var(--text-muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.user_agent || '')}">${esc(r.user_agent || '')}</td>
<td>${badge}</td>`;
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 = '<tr><td colspan="2" style="color:var(--text-muted);text-align:center;padding:2rem">No admins</td></tr>';
return;
}
const me = _tokenPayload().sub;
for (const u of rows) {
const roleBadge = u.role === "admin"
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td>
<td style="text-align:right">
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
</td>`;
tbody.appendChild(tr);
}
tbody.querySelectorAll("[data-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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
}
})();

168
src/static/app.js Normal file
View File

@@ -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 = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
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(() => {});
}

136
src/static/index.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f0f1a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Lagomare Gates</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
<link rel="apple-touch-icon" href="/static/logo.svg" />
<link rel="stylesheet" href="/static/style.css" />
<style>
/* ── Login view ──────────────────────────────────────────────────────── */
#login-view {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
#login-view h1 {
font-size: 1.6rem;
font-weight: 800;
margin-bottom: .25rem;
letter-spacing: -.5px;
}
#login-view .subtitle {
color: var(--text-muted);
font-size: .9rem;
margin-bottom: 2rem;
}
#login-view .card { width: 100%; max-width: 380px; }
#keypass-input {
font-size: 1.4rem;
font-family: monospace;
letter-spacing: .15em;
text-align: center;
text-transform: uppercase;
}
#keypass-input::placeholder { text-transform: none; letter-spacing: normal; font-size: 1rem; font-family: inherit; }
/* ── Gates view ──────────────────────────────────────────────────────── */
#gates-view {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.app-header h2 { font-size: 1.1rem; font-weight: 800; }
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
#gates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 1.25rem;
flex: 1;
}
/* ── Loading state ───────────────────────────────────────────────────── */
#loading-gates {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
</style>
</head>
<body>
<!-- ── Login view ──────────────────────────────────────────────────────── -->
<div id="login-view">
<img src="/static/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
<h1>Lagomare Gates</h1>
<p class="subtitle">Enter your keypass to continue</p>
<div class="card">
<form id="login-form">
<div class="field">
<label for="keypass-input">Keypass</label>
<input
id="keypass-input"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="characters"
spellcheck="false"
placeholder="Enter keypass…"
maxlength="20"
/>
</div>
<p id="login-error" class="error-msg hidden"></p>
<button id="login-btn" type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">
Access
</button>
</form>
</div>
</div>
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
<div id="gates-view" class="hidden">
<header class="app-header">
<div style="display:flex;align-items:center;gap:.6rem">
<img src="/static/logo.svg" alt="" style="width:32px;height:32px;object-fit:contain" />
<div>
<div class="app-header h2">Lagomare Gates</div>
<div class="sub">Select a gate to open</div>
</div>
</div>
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">
Logout
</button>
</header>
<div id="loading-gates">Loading gates…</div>
<div id="gates-grid" class="hidden"></div>
</div>
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div>
<script src="/static/app.js"></script>
</body>
</html>

53
src/static/logo.svg Normal file
View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="270.93332mm"
height="270.93332mm"
viewBox="0 0 270.93332 270.93332"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.5096119"
inkscape:cx="651.47616"
inkscape:cy="528.83381"
inkscape:window-width="1912"
inkscape:window-height="1012"
inkscape:window-x="3844"
inkscape:window-y="64"
inkscape:window-maximized="1"
inkscape:current-layer="g17635" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(30.162493,-12.96459)"><g
id="g17635"><path
id="path17641-7"
style="fill:#53c8f2;fill-opacity:1;stroke-width:0.290761"
d="M -0.24536846,190.40434 A 114.65436,117.11124 0 0 0 105.87478,263.1782 114.65436,117.11124 0 0 0 210.10407,194.85989 Z" /><path
id="path17641"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.290761"
d="M 104.80705 28.22154 A 114.65436 117.11124 0 0 0 -9.8474539 145.33274 A 114.65436 117.11124 0 0 0 -1.3130911 189.67008 L 209.03634 194.12563 A 114.65436 117.11124 0 0 0 219.46155 145.33274 A 114.65436 117.11124 0 0 0 104.80705 28.22154 z " /><path
style="fill:#0076be"
d="m 98.821875,266.26028 c -16.908838,-1.37674 -31.369652,-5.41867 -44.8189,-12.52729 -5.27602,-2.78865 -5.363582,-2.54035 0.961449,-2.72632 12.928382,-0.38012 27.983323,-2.9765 51.640756,-8.90599 29.16078,-7.30885 48.62869,-8.92742 68.19274,-5.66955 6.323,1.05292 6.36325,0.20089 -0.27106,5.73791 -4.73434,3.95129 -20.98907,15.40667 -40.17232,20.54385 -10.94342,2.93061 -25.39192,4.37307 -35.532665,3.54739 z M 38.354739,245.77789 c -8.423996,-0.36965 -18.734652,-2.00339 -26.580776,-4.21174 -3.7335551,-1.05084 -11.60348692,-3.65489 -11.11249904,-3.67697 0.14552083,-0.007 1.87192694,0.16411 3.83645814,0.37923 22.7878619,2.49532 55.0113749,-1.06012 90.0970339,-9.94101 38.263784,-9.68533 55.558294,-10.77971 85.453994,-5.40744 3.2103,0.5769 10.78472,2.12065 11.68452,2.38144 0.16909,0.049 -0.30201,0.81348 -1.12051,1.62771 -1.75286,1.74373 -2.5477,2.39722 -3.82477,3.67197 -1.48656,1.50769 -1.81674,1.72197 -2.5059,1.62624 -0.43656,-0.0606 -2.57969,-0.4208 -4.7625,-0.80035 -13.14413,-2.28551 -27.95175,-2.84874 -39.95208,-1.51965 -14.04132,1.55514 -19.83368,2.63824 -37.57083,7.02532 -25.395747,6.28133 -36.363028,8.20339 -50.535419,8.85656 -6.291794,0.28997 -6.239963,0.29002 -13.106722,-0.0113 z"
id="path17636"
sodipodi:nodetypes="sssssssssscsssssssssssss" /><path
style="fill:#018133"
d="m 21.926324,229.19408 c -1.921696,-2.03737 -6.600013,-7.59333 -6.66777,-7.91863 -0.02475,-0.11884 1.979055,-0.36339 4.452909,-0.54345 14.173177,-1.03154 29.091327,-3.75294 47.757289,-8.71197 41.893298,-11.12989 68.057038,-12.00796 116.485848,-3.90934 7.52339,1.25812 18.20849,3.47553 18.94699,3.93195 0.24905,0.15392 -5.53193,8.13374 -6.42421,8.8677 -0.52057,0.42821 -0.17862,0.50076 -11.13676,-2.363 -27.27312,-7.12747 -49.12082,-8.03892 -75.80312,-3.16239 -8.13419,1.48663 -14.456106,3.0035 -27.952021,6.70675 -23.834435,6.54013 -37.829029,8.88515 -53.044572,8.88847 -4.928889,0.001 -4.928889,0.001 -6.614583,-1.78609 z M 10.940218,215.18929 C 10.7727,215.07086 9.308527,213.07792 7.6865011,210.76054 -46.070447,133.95833 10.150848,28.258087 104.775,28.226888 c 91.16273,-0.03006 148.15706,96.004452 103.65712,174.660552 -2.29018,4.04801 -0.94258,3.84897 -12.18189,1.79932 -56.07762,-10.22656 -79.88335,-9.81532 -127.987728,2.21095 -16.750944,4.1878 -25.743811,5.94968 -36.380206,7.12762 -8.122432,0.89952 -20.354505,1.57938 -20.942078,1.16396 z M 160.09104,190.34247 c 5.6835,-1.6694 6.92602,-3.65638 2.85988,-4.57337 -6.97918,-1.57394 -12.00605,-3.58885 -20.17398,-8.08627 -4.12981,-2.27396 -5.35747,-3.05783 -4.78903,-3.05783 0.0942,0 2.03597,-0.42732 4.31509,-0.94959 2.27913,-0.52227 5.33324,-1.14735 6.78692,-1.38906 5.04315,-0.83856 2.13904,-3.69129 -12.43263,-12.21268 -16.28141,-9.52123 -20.26575,-14.88552 -22.20725,-29.89867 -2.81089,-21.73586 1.89757,-46.535249 13.85881,-72.994131 4.72428,-10.450335 2.92396,-11.978673 -7.79114,-6.614124 -20.41528,10.220968 -35.46783,32.692089 -40.742914,60.822835 -4.304937,22.9572 -5.164514,25.32568 -10.024281,27.62092 -6.201621,2.929 -12.243013,2.35805 -21.954314,-2.0748 -12.93987,-5.90657 -19.12784,-4.78933 -25.171529,4.54475 -1.033332,1.59591 -1.033332,1.59591 -5.015979,3.34328 -3.963534,1.73897 -6.201655,3.01272 -7.6562336,4.35728 -1.7500875,1.61772 -3.0197311,4.55572 -1.8165167,4.2035 8.8532733,-2.59171 10.5706133,-2.89136 16.4703113,-2.87384 12.052398,0.0358 20.193637,2.64899 31.486663,10.10664 20.499318,13.53727 34.004722,17.54724 60.456043,17.95037 13.50401,0.2058 14.60156,0.51877 26.32604,7.50685 7.58957,4.52357 12.33804,5.70074 17.21604,4.26794 z"
id="path17635" /></g></g></svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

18
src/static/manifest.json Normal file
View File

@@ -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"
}
]
}

191
src/static/style.css Normal file
View File

@@ -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; }

26
src/static/sw.js Normal file
View File

@@ -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))
);
});