First commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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
9
requirements.txt
Normal 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
|
||||||
BIN
src/__pycache__/auth.cpython-313.pyc
Normal file
BIN
src/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/database.cpython-313.pyc
Normal file
BIN
src/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/dependencies.cpython-313.pyc
Normal file
BIN
src/__pycache__/dependencies.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
src/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/auth.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/config.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/database.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/dependencies.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/dependencies.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
53
src/core/auth.py
Normal file
53
src/core/auth.py
Normal 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
3
src/core/config.py
Normal 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
91
src/core/database.py
Normal 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
50
src/core/dependencies.py
Normal 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
123
src/core/schemas.py
Normal 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
98
src/main.py
Normal 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
9
src/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from .credential import Credential
|
||||||
|
from .gate import Gate
|
||||||
|
from .status import Status
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Credential",
|
||||||
|
"Gate",
|
||||||
|
"Status"
|
||||||
|
]
|
||||||
BIN
src/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/models/__pycache__/credential.cpython-313.pyc
Normal file
BIN
src/models/__pycache__/credential.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/models/__pycache__/gate.cpython-313.pyc
Normal file
BIN
src/models/__pycache__/gate.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/models/__pycache__/status.cpython-313.pyc
Normal file
BIN
src/models/__pycache__/status.cpython-313.pyc
Normal file
Binary file not shown.
5
src/models/credential.py
Normal file
5
src/models/credential.py
Normal 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
9
src/models/gate.py
Normal 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
5
src/models/status.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class Status(Enum):
|
||||||
|
ENABLED = 1
|
||||||
|
DISABLED = 0
|
||||||
1
src/routers/__init__.py
Normal file
1
src/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# routers package
|
||||||
BIN
src/routers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/admins.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/admins.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/auth.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/credentials.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/credentials.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/gates.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/gates.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/keypasses.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/keypasses.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/stats.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/stats.cpython-313.pyc
Normal file
Binary file not shown.
55
src/routers/admins.py
Normal file
55
src/routers/admins.py
Normal 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
45
src/routers/auth.py
Normal 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)
|
||||||
40
src/routers/credentials.py
Normal file
40
src/routers/credentials.py
Normal 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
189
src/routers/gates.py
Normal 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
89
src/routers/keypasses.py
Normal 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
21
src/routers/stats.py
Normal 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
9
src/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from .avconnect import AVConnectAPI
|
||||||
|
from .gates import GatesService, OpenResult, call_open_gate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AVConnectAPI",
|
||||||
|
"GatesService",
|
||||||
|
"OpenResult",
|
||||||
|
"call_open_gate",
|
||||||
|
]
|
||||||
BIN
src/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/services/__pycache__/avconnect.cpython-313.pyc
Normal file
BIN
src/services/__pycache__/avconnect.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/services/__pycache__/gates.cpython-313.pyc
Normal file
BIN
src/services/__pycache__/gates.cpython-313.pyc
Normal file
Binary file not shown.
53
src/services/avconnect.py
Normal file
53
src/services/avconnect.py
Normal 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
38
src/services/gates.py
Normal 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
384
src/static/admin.html
Normal 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 & 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 & 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
601
src/static/admin.js
Normal 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, "&")
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
168
src/static/app.js
Normal file
168
src/static/app.js
Normal 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
136
src/static/index.html
Normal 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
53
src/static/logo.svg
Normal 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
18
src/static/manifest.json
Normal 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
191
src/static/style.css
Normal 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
26
src/static/sw.js
Normal 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))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user