Compare commits
4 Commits
0cb35a30cb
...
a5470544a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5470544a1 | ||
|
|
54f1ebb62d | ||
|
|
0264425383 | ||
|
|
ff097b31d1 |
@@ -7,3 +7,4 @@ cryptography>=42.0.0
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
fake-useragent>=1.5.0
|
fake-useragent>=1.5.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ class AdminUser(Base):
|
|||||||
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfig(Base):
|
||||||
|
__tablename__ = "telegram_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
bot_token_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted
|
||||||
|
chat_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class KeypassCreate(BaseModel):
|
|||||||
expires_at: Optional[datetime] = None # None = never expires
|
expires_at: Optional[datetime] = None # None = never expires
|
||||||
gate_ids: list[int] = [] # empty = all gates
|
gate_ids: list[int] = [] # empty = all gates
|
||||||
code: Optional[str] = None # None = auto-generate
|
code: Optional[str] = None # None = auto-generate
|
||||||
|
# Auto-generation options (ignored when `code` is supplied manually)
|
||||||
|
length: int = 12 # 6–32
|
||||||
|
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
|
||||||
|
|
||||||
|
|
||||||
class KeypassPatch(BaseModel):
|
class KeypassPatch(BaseModel):
|
||||||
@@ -137,3 +140,10 @@ class AccessLogResponse(BaseModel):
|
|||||||
user_agent: Optional[str]
|
user_agent: Optional[str]
|
||||||
success: bool
|
success: bool
|
||||||
error: Optional[str]
|
error: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class StatsPage(BaseModel):
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
items: list[AccessLogResponse]
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from routers.gates import router as gates_router
|
|||||||
from routers.credentials import router as credentials_router
|
from routers.credentials import router as credentials_router
|
||||||
from routers.admins import router as admins_router
|
from routers.admins import router as admins_router
|
||||||
from routers.stats import router as stats_router
|
from routers.stats import router as stats_router
|
||||||
|
from routers.telegram import router as telegram_router
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -87,7 +88,7 @@ async def _security_headers(request: Request, call_next) -> Response:
|
|||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ app.include_router(gates_router)
|
|||||||
app.include_router(credentials_router)
|
app.include_router(credentials_router)
|
||||||
app.include_router(admins_router)
|
app.include_router(admins_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
|
app.include_router(telegram_router)
|
||||||
|
|
||||||
# ── Static / frontend ─────────────────────────────────────────────────────────
|
# ── Static / frontend ─────────────────────────────────────────────────────────
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -7,15 +8,33 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from core.auth import decrypt_secret
|
from core.auth import decrypt_secret
|
||||||
from core.config import utcnow
|
from core.config import utcnow
|
||||||
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, get_db
|
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, TelegramConfig, get_db
|
||||||
from core.dependencies import require_admin, require_manager, require_keypass
|
from core.dependencies import require_admin, require_manager, require_keypass
|
||||||
from core.schemas import GateCreate, GatePublicResponse, GateResponse
|
from core.schemas import GateCreate, GatePublicResponse, GateResponse
|
||||||
from services.gates import call_open_gate
|
from services.gates import call_open_gate
|
||||||
|
from services.telegram import send_gate_notification
|
||||||
|
|
||||||
router = APIRouter(tags=["gates"])
|
router = APIRouter(tags=["gates"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None:
|
||||||
|
"""Fire a Telegram notification in a background thread if configured and enabled."""
|
||||||
|
try:
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg or not cfg.enabled:
|
||||||
|
return
|
||||||
|
token = decrypt_secret(cfg.bot_token_enc)
|
||||||
|
chat_id = cfg.chat_id
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
threading.Thread(
|
||||||
|
target=send_gate_notification,
|
||||||
|
args=(token, chat_id, gate_name, opened_by, ip),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/admin/gates", response_model=list[GateResponse])
|
@router.get("/api/admin/gates", response_model=list[GateResponse])
|
||||||
@@ -120,6 +139,7 @@ async def admin_open_gate(
|
|||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
|
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
|
||||||
|
_notify(db, gate_db.name, f"[{caller['sub']}]", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|
||||||
|
|
||||||
@@ -185,5 +205,6 @@ async def open_gate(
|
|||||||
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
|
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
|
||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass_id=%d ip=%r", gate_db.id, gate_db.name, _kp.id, ip)
|
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
|
||||||
|
_notify(db, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import qrcode
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.config import utcnow
|
from core.config import utcnow
|
||||||
@@ -14,9 +16,66 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
||||||
|
|
||||||
|
# ── Word list for passphrase mode ─────────────────────────────────────────────
|
||||||
|
_WORDS = [
|
||||||
|
"apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain",
|
||||||
|
"chalk", "chase", "chest", "clear", "cliff", "clock", "cloud", "coast",
|
||||||
|
"coral", "crane", "creek", "crisp", "crown", "curve", "dance", "delta",
|
||||||
|
"depot", "drift", "drive", "drops", "dunes", "eagle", "earth", "ember",
|
||||||
|
"fence", "field", "final", "flame", "flash", "fleet", "flint", "floor",
|
||||||
|
"focus", "forge", "forth", "frost", "fruit", "glade", "gleam", "globe",
|
||||||
|
"gloom", "gloss", "grace", "grain", "grand", "grape", "grass", "gravel",
|
||||||
|
"green", "grove", "guard", "guide", "haven", "heart", "honey", "honor",
|
||||||
|
"hatch", "image", "inlet", "ivory", "joker", "karma", "knoll", "lake",
|
||||||
|
"lance", "large", "laser", "latch", "layer", "ledge", "light", "linen",
|
||||||
|
"links", "liver", "lunar", "mango", "maple", "march", "marsh", "merit",
|
||||||
|
"metal", "minor", "mirth", "misty", "mixer", "mocha", "model", "money",
|
||||||
|
"mount", "mouse", "named", "nerve", "night", "noble", "north", "notch",
|
||||||
|
"novel", "oaken", "ocean", "olive", "ombre", "ozone", "panel", "paper",
|
||||||
|
"patch", "pause", "peace", "pearl", "petal", "pilot", "pinch", "pixel",
|
||||||
|
"plain", "plane", "plaza", "plumb", "plume", "plush", "polar", "porch",
|
||||||
|
"power", "prism", "prize", "probe", "proxy", "pulse", "quail", "quartz",
|
||||||
|
"queen", "quest", "queue", "quiet", "quota", "quote", "radar", "radix",
|
||||||
|
"rally", "ranch", "rapid", "raven", "reach", "realm", "relay", "repay",
|
||||||
|
"resin", "ridge", "rivet", "river", "rogue", "round", "route", "rover",
|
||||||
|
"royal", "rusty", "saint", "salsa", "salvo", "sandy", "score", "scout",
|
||||||
|
"serum", "shade", "shaft", "shale", "shall", "shape", "sharp", "sheen",
|
||||||
|
"shelf", "shell", "shift", "shiny", "shore", "short", "sigma", "silky",
|
||||||
|
"silva", "since", "sixth", "slate", "slope", "smoke", "solar", "solid",
|
||||||
|
"sonic", "sound", "south", "space", "spark", "spear", "spend", "spire",
|
||||||
|
"split", "spoke", "spore", "sport", "spray", "spree", "sprig", "squad",
|
||||||
|
"stack", "staff", "stage", "stain", "stake", "stale", "stall", "stamp",
|
||||||
|
"stand", "stark", "start", "state", "stays", "steel", "steep", "steer",
|
||||||
|
"stern", "stock", "stone", "storm", "story", "stove", "strap", "straw",
|
||||||
|
"strip", "strut", "study", "stuff", "sugar", "suite", "sunny", "surge",
|
||||||
|
"swamp", "swarm", "swept", "swing", "sword", "synth", "table", "talon",
|
||||||
|
"tango", "taste", "tawny", "tempo", "thatch", "theme", "think", "thorn",
|
||||||
|
"three", "threw", "tiger", "tidal", "titan", "token", "topaz", "torch",
|
||||||
|
"total", "touch", "tough", "towel", "tower", "trace", "track", "trade",
|
||||||
|
"trail", "train", "trait", "tramp", "trawl", "trend", "trial", "tribe",
|
||||||
|
"trick", "tried", "troop", "trove", "truce", "trunk", "trust", "tuber",
|
||||||
|
"tundra", "tuner", "twill", "twist", "ultra", "umbra", "uncle", "union",
|
||||||
|
"unity", "until", "upper", "urged", "usage", "utmost", "valor", "vault",
|
||||||
|
"vibes", "vigor", "viper", "vista", "vital", "vivid", "voice", "voter",
|
||||||
|
"vroom", "waltz", "water", "waves", "wedge", "weird", "wheat", "wheel",
|
||||||
|
"where", "whirl", "white", "whole", "widow", "width", "winds", "windy",
|
||||||
|
"witch", "world", "wreck", "wrist", "xerox", "yacht", "yards", "years",
|
||||||
|
"yield", "young", "youth", "zeal", "zebra", "zones", "zenith",
|
||||||
|
]
|
||||||
|
|
||||||
def _generate_code(length: int = 12) -> str:
|
_CHARSETS = {
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
"alphanumeric": string.ascii_uppercase + string.digits,
|
||||||
|
"alpha": string.ascii_uppercase,
|
||||||
|
"numeric": string.digits,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code(length: int = 12, charset: str = "alphanumeric") -> str:
|
||||||
|
if charset == "passphrase":
|
||||||
|
# 4 random words joined by hyphens; `length` is ignored for passphrases
|
||||||
|
return "-".join(secrets.choice(_WORDS).upper() for _ in range(4))
|
||||||
|
alphabet = _CHARSETS.get(charset, _CHARSETS["alphanumeric"])
|
||||||
|
length = max(6, min(length, 32))
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +92,7 @@ async def create_keypass(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_manager),
|
_: dict = Depends(require_manager),
|
||||||
):
|
):
|
||||||
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code()
|
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code(req.length, req.charset)
|
||||||
if db.query(Keypass).filter(Keypass.code == code).first():
|
if db.query(Keypass).filter(Keypass.code == code).first():
|
||||||
raise HTTPException(409, "A keypass with this code already exists")
|
raise HTTPException(409, "A keypass with this code already exists")
|
||||||
kp = Keypass(
|
kp = Keypass(
|
||||||
@@ -88,3 +147,28 @@ async def revoke_keypass(
|
|||||||
kp.revoked = True
|
kp.revoked = True
|
||||||
kp.revoked_at = utcnow()
|
kp.revoked_at = utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{kp_id}/qr")
|
||||||
|
async def keypass_qr(
|
||||||
|
kp_id: int,
|
||||||
|
request: Request,
|
||||||
|
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")
|
||||||
|
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
url = f"{base_url}/?k={kp.code}"
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(box_size=10, border=4)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
return Response(content=buf.read(), media_type="image/png")
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.database import GateAccessLog, get_db
|
from core.database import GateAccessLog, get_db
|
||||||
from core.dependencies import require_manager
|
from core.dependencies import require_manager
|
||||||
from core.schemas import AccessLogResponse
|
from core.schemas import StatsPage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
|
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AccessLogResponse])
|
@router.get("", response_model=StatsPage)
|
||||||
async def get_stats(
|
async def get_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_manager),
|
_: dict = Depends(require_manager),
|
||||||
|
gate_id: Optional[int] = Query(None),
|
||||||
|
keypass_code: Optional[str] = Query(None),
|
||||||
|
success: Optional[bool] = Query(None),
|
||||||
|
date_from: Optional[datetime] = Query(None),
|
||||||
|
date_to: Optional[datetime] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
):
|
):
|
||||||
return (
|
q = db.query(GateAccessLog)
|
||||||
db.query(GateAccessLog)
|
if gate_id is not None:
|
||||||
.order_by(GateAccessLog.timestamp.desc())
|
q = q.filter(GateAccessLog.gate_id == gate_id)
|
||||||
.limit(500)
|
if keypass_code:
|
||||||
|
q = q.filter(GateAccessLog.keypass_code.ilike(f"%{keypass_code}%"))
|
||||||
|
if success is not None:
|
||||||
|
q = q.filter(GateAccessLog.success == success)
|
||||||
|
if date_from is not None:
|
||||||
|
q = q.filter(GateAccessLog.timestamp >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
q = q.filter(GateAccessLog.timestamp <= date_to)
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
items = (
|
||||||
|
q.order_by(GateAccessLog.timestamp.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
return StatsPage(total=total, page=page, page_size=page_size, items=items)
|
||||||
|
|||||||
75
src/routers/telegram.py
Normal file
75
src/routers/telegram.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.auth import decrypt_secret, encrypt_secret
|
||||||
|
from core.database import TelegramConfig, get_db
|
||||||
|
from core.dependencies import require_admin
|
||||||
|
from services.telegram import send_test_message
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/telegram", tags=["admin-telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
chat_id: str
|
||||||
|
configured: bool # True when a bot token has been saved
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigUpsert(BaseModel):
|
||||||
|
bot_token: Optional[str] = None # None = keep existing token
|
||||||
|
chat_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TelegramConfigRead)
|
||||||
|
async def get_telegram_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
return TelegramConfigRead(enabled=False, chat_id="", configured=False)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=TelegramConfigRead)
|
||||||
|
async def upsert_telegram_config(
|
||||||
|
req: TelegramConfigUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if cfg:
|
||||||
|
if req.bot_token:
|
||||||
|
cfg.bot_token_enc = encrypt_secret(req.bot_token)
|
||||||
|
cfg.chat_id = req.chat_id
|
||||||
|
cfg.enabled = req.enabled
|
||||||
|
else:
|
||||||
|
if not req.bot_token:
|
||||||
|
raise HTTPException(422, "bot_token is required for initial setup")
|
||||||
|
cfg = TelegramConfig(
|
||||||
|
bot_token_enc=encrypt_secret(req.bot_token),
|
||||||
|
chat_id=req.chat_id,
|
||||||
|
enabled=req.enabled,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_telegram(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(422, "Telegram is not configured yet")
|
||||||
|
ok, err = send_test_message(decrypt_secret(cfg.bot_token_enc), cfg.chat_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(502, err or "Telegram API error")
|
||||||
|
return {"success": True}
|
||||||
52
src/services/telegram.py
Normal file
52
src/services/telegram.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
def send_gate_notification(
|
||||||
|
bot_token: str,
|
||||||
|
chat_id: str,
|
||||||
|
gate_name: str,
|
||||||
|
opened_by: str,
|
||||||
|
ip: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Telegram message. Runs synchronously; call from a background thread or task."""
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M UTC")
|
||||||
|
text = (
|
||||||
|
f"🔓 *Gate opened*\n"
|
||||||
|
f"*Gate:* {gate_name}\n"
|
||||||
|
f"*By:* `{opened_by}`\n"
|
||||||
|
f"*Time:* {ts}"
|
||||||
|
)
|
||||||
|
if ip:
|
||||||
|
text += f"\n*IP:* `{ip}`"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
logger.warning("Telegram notification failed: %s", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Telegram notification error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_message(bot_token: str, chat_id: str) -> tuple[bool, str]:
|
||||||
|
"""Send a test message. Returns (success, error_or_empty)."""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": "✅ Lagomare Gates — Telegram notifications are working!"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
return True, ""
|
||||||
|
return False, resp.json().get("description", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</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" data-tab="stats">Statistics</button>
|
||||||
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
||||||
|
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -206,6 +207,40 @@
|
|||||||
<h3>Gate Access Log</h3>
|
<h3>Gate Access Log</h3>
|
||||||
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
|
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:1rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Keypass code</label>
|
||||||
|
<input id="filter-keypass" type="text" placeholder="Any"
|
||||||
|
style="width:140px;font-family:monospace;text-transform:uppercase" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Gate</label>
|
||||||
|
<select id="filter-gate" style="width:160px">
|
||||||
|
<option value="">Any</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Result</label>
|
||||||
|
<select id="filter-success" style="width:110px">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="true">Success</option>
|
||||||
|
<option value="false">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
|
||||||
|
<input id="filter-from" type="datetime-local" style="width:180px" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
|
||||||
|
<input id="filter-to" type="datetime-local" style="width:180px" />
|
||||||
|
</div>
|
||||||
|
<button id="btn-stats-filter" class="btn btn-primary" style="font-size:.85rem;padding:.5rem 1rem">Filter</button>
|
||||||
|
<button id="btn-stats-reset" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap card" style="padding:0">
|
<div class="table-wrap card" style="padding:0">
|
||||||
<table id="stats-table">
|
<table id="stats-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -221,6 +256,16 @@
|
|||||||
<tbody id="stats-body"></tbody>
|
<tbody id="stats-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:1rem;font-size:.9rem;flex-wrap:wrap;gap:.5rem">
|
||||||
|
<span id="stats-total-label" style="color:var(--text-muted)"></span>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<button id="btn-stats-prev" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">← Prev</button>
|
||||||
|
<span id="stats-page-label" style="color:var(--text-muted);min-width:90px;text-align:center"></span>
|
||||||
|
<button id="btn-stats-next" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Admins pane ──────────────────────────────────────────────────── -->
|
<!-- ── Admins pane ──────────────────────────────────────────────────── -->
|
||||||
@@ -242,6 +287,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Telegram / Notifications pane ────────────────────────────────── -->
|
||||||
|
<div id="tab-telegram" class="tab-pane">
|
||||||
|
<h3 style="margin-bottom:1rem">Telegram Notifications</h3>
|
||||||
|
<div class="card" style="max-width:480px">
|
||||||
|
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1.25rem">
|
||||||
|
Send a message to a Telegram group or chat every time a gate is opened.
|
||||||
|
Create a bot via <a href="https://t.me/BotFather" target="_blank" rel="noopener" style="color:var(--primary)">@BotFather</a>,
|
||||||
|
add it to your group, and paste its token and the chat ID below.
|
||||||
|
</p>
|
||||||
|
<form id="telegram-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-token">Bot Token</label>
|
||||||
|
<input id="tg-token" type="password" autocomplete="off"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-chat-id">Chat / Group ID</label>
|
||||||
|
<input id="tg-chat-id" type="text" autocomplete="off"
|
||||||
|
placeholder="e.g. -1001234567890" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
|
||||||
|
<input type="checkbox" id="tg-enabled" checked style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
|
||||||
|
<span style="font-weight:600">Enable notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="tg-status" style="font-size:.85rem;color:var(--text-muted);margin-bottom:.75rem"></p>
|
||||||
|
<p id="tg-error" class="error-msg hidden"></p>
|
||||||
|
<div style="display:flex;gap:.75rem;flex-wrap:wrap">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" id="btn-tg-test" class="btn btn-ghost">Send test message</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
</div><!-- /.tab-content -->
|
||||||
</div><!-- /#admin-view -->
|
</div><!-- /#admin-view -->
|
||||||
|
|
||||||
@@ -258,7 +339,23 @@
|
|||||||
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
|
<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"
|
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
|
||||||
spellcheck="false" placeholder="Auto-generated"
|
spellcheck="false" placeholder="Auto-generated"
|
||||||
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="32" />
|
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="40" />
|
||||||
|
</div>
|
||||||
|
<!-- Auto-generation options — hidden when a manual code is typed -->
|
||||||
|
<div id="kp-autogen-options" class="field" style="background:var(--surface2);border-radius:8px;padding:.75rem;border:1px solid var(--border);display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-charset" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Character set</label>
|
||||||
|
<select id="kp-charset" style="width:auto;font-size:.9rem">
|
||||||
|
<option value="alphanumeric">A–Z + 0–9</option>
|
||||||
|
<option value="alpha">A–Z only</option>
|
||||||
|
<option value="numeric">0–9 only</option>
|
||||||
|
<option value="passphrase" selected>Passphrase (4 words)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="kp-length-wrap" style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-length" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Length <span id="kp-length-val" style="font-weight:700;color:var(--text)">12</span></label>
|
||||||
|
<input id="kp-length" type="range" min="6" max="32" value="12" style="width:100%;cursor:pointer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="kp-expires-field">
|
<div class="field" id="kp-expires-field">
|
||||||
<label for="kp-expires">Expiry date & time</label>
|
<label for="kp-expires">Expiry date & time</label>
|
||||||
@@ -418,6 +515,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── QR Code modal ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="qr-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal" style="text-align:center;max-width:340px">
|
||||||
|
<h3 style="margin-bottom:.2rem">Keypass QR Code</h3>
|
||||||
|
<p id="qr-modal-desc" style="color:var(--text-muted);font-size:.85rem;margin-bottom:1.25rem"></p>
|
||||||
|
<div style="display:flex;justify-content:center;align-items:center;min-height:220px;background:var(--surface2);border-radius:8px;padding:1rem">
|
||||||
|
<img id="qr-img" src="" alt="QR Code" style="max-width:100%;border-radius:4px;display:block" />
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-muted);font-size:.78rem;margin-top:.75rem">Scan to open the app and login automatically.</p>
|
||||||
|
<div class="modal-actions" style="justify-content:center;margin-top:1rem">
|
||||||
|
<button type="button" id="qr-close" class="btn btn-ghost">Close</button>
|
||||||
|
<a id="qr-download" download="keypass-qr.png" class="btn btn-primary" style="text-decoration:none">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ async function loadKeypasses() {
|
|||||||
<td>${expiresCell}</td>
|
<td>${expiresCell}</td>
|
||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
|
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
|
||||||
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
|
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
|
||||||
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
${!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>` : ""}
|
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"
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
@@ -189,6 +191,33 @@ async function loadKeypasses() {
|
|||||||
document.getElementById("kp-edit-modal").classList.remove("hidden");
|
document.getElementById("kp-edit-modal").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
tbody.querySelectorAll("[data-qr-kp-id]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const id = btn.dataset.qrKpId;
|
||||||
|
const desc = btn.dataset.qrKpDesc;
|
||||||
|
document.getElementById("qr-modal-desc").textContent = desc;
|
||||||
|
const img = document.getElementById("qr-img");
|
||||||
|
const dl = document.getElementById("qr-download");
|
||||||
|
img.src = "";
|
||||||
|
dl.removeAttribute("href");
|
||||||
|
document.getElementById("qr-modal").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/keypasses/${id}/qr`, {
|
||||||
|
headers: { "Authorization": `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to load QR code");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
img.src = blobUrl;
|
||||||
|
dl.href = blobUrl;
|
||||||
|
dl.download = `keypass-${id}-qr.png`;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, true);
|
||||||
|
document.getElementById("qr-modal").classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
|
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
if (!confirm("Revoke this keypass?")) return;
|
if (!confirm("Revoke this keypass?")) return;
|
||||||
@@ -207,6 +236,12 @@ let _allGates = [];
|
|||||||
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
||||||
document.getElementById("kp-desc").value = "";
|
document.getElementById("kp-desc").value = "";
|
||||||
document.getElementById("kp-code").value = "";
|
document.getElementById("kp-code").value = "";
|
||||||
|
// Reset complexity — default to passphrase
|
||||||
|
document.getElementById("kp-charset").value = "passphrase";
|
||||||
|
document.getElementById("kp-length").value = 12;
|
||||||
|
document.getElementById("kp-length-val").textContent = "12";
|
||||||
|
document.getElementById("kp-length-wrap").style.display = "none";
|
||||||
|
document.getElementById("kp-autogen-options").style.display = "";
|
||||||
// Reset never-expires
|
// Reset never-expires
|
||||||
const neverCb = document.getElementById("kp-never-expires");
|
const neverCb = document.getElementById("kp-never-expires");
|
||||||
neverCb.checked = false;
|
neverCb.checked = false;
|
||||||
@@ -234,6 +269,17 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
|||||||
document.getElementById("keypass-modal").classList.remove("hidden");
|
document.getElementById("keypass-modal").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-generation options — hide when a manual code is typed, hide length for passphrase
|
||||||
|
document.getElementById("kp-code").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-autogen-options").style.display = e.target.value.trim() ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-charset").addEventListener("change", e => {
|
||||||
|
document.getElementById("kp-length-wrap").style.display = e.target.value === "passphrase" ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-length").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-length-val").textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Never expires toggle
|
// Never expires toggle
|
||||||
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
||||||
const kpExpInput = document.getElementById("kp-expires");
|
const kpExpInput = document.getElementById("kp-expires");
|
||||||
@@ -285,6 +331,13 @@ document.getElementById("kp-edit-gate-checks").addEventListener("change", () =>
|
|||||||
document.getElementById("kp-edit-cancel").addEventListener("click", () => {
|
document.getElementById("kp-edit-cancel").addEventListener("click", () => {
|
||||||
document.getElementById("kp-edit-modal").classList.add("hidden");
|
document.getElementById("kp-edit-modal").classList.add("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("qr-close").addEventListener("click", () => {
|
||||||
|
const img = document.getElementById("qr-img");
|
||||||
|
if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src);
|
||||||
|
img.src = "";
|
||||||
|
document.getElementById("qr-modal").classList.add("hidden");
|
||||||
|
});
|
||||||
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById("kp-edit-id").value;
|
const id = document.getElementById("kp-edit-id").value;
|
||||||
@@ -309,6 +362,8 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const desc = document.getElementById("kp-desc").value.trim();
|
const desc = document.getElementById("kp-desc").value.trim();
|
||||||
const code = document.getElementById("kp-code").value.trim() || null;
|
const code = document.getElementById("kp-code").value.trim() || null;
|
||||||
|
const charset = document.getElementById("kp-charset").value;
|
||||||
|
const length = parseInt(document.getElementById("kp-length").value, 10);
|
||||||
const neverExpires = document.getElementById("kp-never-expires").checked;
|
const neverExpires = document.getElementById("kp-never-expires").checked;
|
||||||
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
||||||
const allGates = document.getElementById("kp-all-gates").checked;
|
const allGates = document.getElementById("kp-all-gates").checked;
|
||||||
@@ -316,7 +371,7 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
const errEl = document.getElementById("kp-error");
|
const errEl = document.getElementById("kp-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code });
|
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length });
|
||||||
document.getElementById("keypass-modal").classList.add("hidden");
|
document.getElementById("keypass-modal").classList.add("hidden");
|
||||||
showToast("Keypass created");
|
showToast("Keypass created");
|
||||||
loadKeypasses();
|
loadKeypasses();
|
||||||
@@ -330,6 +385,17 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
async function loadGates() {
|
async function loadGates() {
|
||||||
const rows = await api("GET", "/api/admin/gates");
|
const rows = await api("GET", "/api/admin/gates");
|
||||||
_allGates = rows; // cache for keypass modal
|
_allGates = rows; // cache for keypass modal
|
||||||
|
// Populate the stats gate filter dropdown
|
||||||
|
const filterGate = document.getElementById("filter-gate");
|
||||||
|
const prevGateVal = filterGate.value;
|
||||||
|
filterGate.innerHTML = '<option value="">Any</option>';
|
||||||
|
for (const g of rows) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g.id;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
filterGate.appendChild(opt);
|
||||||
|
}
|
||||||
|
filterGate.value = prevGateVal; // restore selection if still valid
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
const tbody = document.getElementById("gates-body");
|
const tbody = document.getElementById("gates-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
@@ -485,16 +551,47 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Statistics ───────────────────────────────────────────────────────────────
|
// ── Statistics ───────────────────────────────────────────────────────────────
|
||||||
|
const STATS_PAGE_SIZE = 50;
|
||||||
|
let _statsPage = 1;
|
||||||
|
let _statsTotal = 0;
|
||||||
|
|
||||||
|
function _buildStatsParams() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const keypass = document.getElementById("filter-keypass").value.trim();
|
||||||
|
if (keypass) params.set("keypass_code", keypass.toUpperCase());
|
||||||
|
const gate = document.getElementById("filter-gate").value;
|
||||||
|
if (gate) params.set("gate_id", gate);
|
||||||
|
const success = document.getElementById("filter-success").value;
|
||||||
|
if (success !== "") params.set("success", success);
|
||||||
|
const from = document.getElementById("filter-from").value;
|
||||||
|
if (from) params.set("date_from", new Date(from).toISOString());
|
||||||
|
const to = document.getElementById("filter-to").value;
|
||||||
|
if (to) params.set("date_to", new Date(to).toISOString());
|
||||||
|
params.set("page", _statsPage);
|
||||||
|
params.set("page_size", STATS_PAGE_SIZE);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const rows = await api("GET", "/api/admin/stats");
|
const data = await api("GET", `/api/admin/stats?${_buildStatsParams()}`);
|
||||||
|
_statsTotal = data.total;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
|
||||||
|
|
||||||
|
document.getElementById("stats-total-label").textContent =
|
||||||
|
`${_statsTotal} record${_statsTotal !== 1 ? "s" : ""}`;
|
||||||
|
document.getElementById("stats-page-label").textContent =
|
||||||
|
`Page ${_statsPage} of ${totalPages}`;
|
||||||
|
document.getElementById("btn-stats-prev").disabled = _statsPage <= 1;
|
||||||
|
document.getElementById("btn-stats-next").disabled = _statsPage >= totalPages;
|
||||||
|
|
||||||
const tbody = document.getElementById("stats-body");
|
const tbody = document.getElementById("stats-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
if (!rows.length) {
|
if (!data.items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No access logs yet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No records match the current filters</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of rows) {
|
for (const r of data.items) {
|
||||||
const badge = r.success
|
const badge = r.success
|
||||||
? '<span class="badge badge-green">OK</span>'
|
? '<span class="badge badge-green">OK</span>'
|
||||||
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
|
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
|
||||||
@@ -511,9 +608,24 @@ async function loadStats() {
|
|||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats);
|
document.getElementById("btn-refresh-stats").addEventListener("click", () => { _statsPage = 1; loadStats(); });
|
||||||
|
document.getElementById("btn-stats-filter").addEventListener("click", () => { _statsPage = 1; loadStats(); });
|
||||||
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats);
|
document.getElementById("btn-stats-reset").addEventListener("click", () => {
|
||||||
|
document.getElementById("filter-keypass").value = "";
|
||||||
|
document.getElementById("filter-gate").value = "";
|
||||||
|
document.getElementById("filter-success").value = "";
|
||||||
|
document.getElementById("filter-from").value = "";
|
||||||
|
document.getElementById("filter-to").value = "";
|
||||||
|
_statsPage = 1;
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
document.getElementById("btn-stats-prev").addEventListener("click", () => {
|
||||||
|
if (_statsPage > 1) { _statsPage--; loadStats(); }
|
||||||
|
});
|
||||||
|
document.getElementById("btn-stats-next").addEventListener("click", () => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
|
||||||
|
if (_statsPage < totalPages) { _statsPage++; loadStats(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Admin users ───────────────────────────────────────────────────────────────
|
// ── Admin users ───────────────────────────────────────────────────────────────
|
||||||
async function loadAdmins() {
|
async function loadAdmins() {
|
||||||
@@ -613,6 +725,52 @@ document.getElementById("chpw-form").addEventListener("submit", async e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Telegram / Notifications ──────────────────────────────────────────────────
|
||||||
|
async function loadTelegram() {
|
||||||
|
try {
|
||||||
|
const cfg = await api("GET", "/api/admin/telegram");
|
||||||
|
document.getElementById("tg-chat-id").value = cfg.chat_id || "";
|
||||||
|
document.getElementById("tg-enabled").checked = cfg.enabled;
|
||||||
|
document.getElementById("tg-status").textContent = cfg.configured
|
||||||
|
? "Bot token is saved. Leave the token field empty to keep it."
|
||||||
|
: "Not configured yet. Enter a bot token to get started.";
|
||||||
|
} catch { /* non-admin: tab hidden anyway */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("telegram-form").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = document.getElementById("tg-token").value.trim() || null;
|
||||||
|
const chat_id = document.getElementById("tg-chat-id").value.trim();
|
||||||
|
const enabled = document.getElementById("tg-enabled").checked;
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/admin/telegram", { bot_token: token, chat_id, enabled });
|
||||||
|
document.getElementById("tg-token").value = "";
|
||||||
|
showToast("Telegram settings saved");
|
||||||
|
loadTelegram();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-tg-test").addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("btn-tg-test");
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
btn.disabled = true;
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/telegram/test");
|
||||||
|
showToast("Test message sent ✓");
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Load all data ─────────────────────────────────────────────────────────────
|
// ── Load all data ─────────────────────────────────────────────────────────────
|
||||||
function loadAllData() {
|
function loadAllData() {
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
@@ -622,6 +780,7 @@ function loadAllData() {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadCredentials();
|
loadCredentials();
|
||||||
loadAdmins();
|
loadAdmins();
|
||||||
|
loadTelegram();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,30 @@ document.getElementById("logout-btn").addEventListener("click", () => {
|
|||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
(function init() {
|
(function init() {
|
||||||
|
// Auto-login when the URL contains ?k=CODE (e.g. scanned from a QR code)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const k = params.get("k");
|
||||||
|
if (k) {
|
||||||
|
// Remove the code from the URL immediately so it doesn't linger in history
|
||||||
|
history.replaceState(null, "", window.location.pathname);
|
||||||
|
fetch("/api/auth/keypass", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: k.toUpperCase() }),
|
||||||
|
})
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
saveToken(data.token);
|
||||||
|
showGatesView();
|
||||||
|
loadGates();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearToken();
|
||||||
|
showLogin();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const t = getToken();
|
const t = getToken();
|
||||||
if (tokenValid(t)) {
|
if (tokenValid(t)) {
|
||||||
showGatesView();
|
showGatesView();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
autocapitalize="characters"
|
autocapitalize="characters"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Enter keypass…"
|
placeholder="Enter keypass…"
|
||||||
maxlength="20"
|
maxlength="40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p id="login-error" class="error-msg hidden"></p>
|
<p id="login-error" class="error-msg hidden"></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user