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

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

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