Compare commits

..

4 Commits

Author SHA1 Message Date
Ettore
a5470544a1 Add Telegram notifications 2026-05-10 11:58:56 +02:00
Ettore
54f1ebb62d Add passphrase keypasses 2026-05-10 11:02:03 +02:00
Ettore
0264425383 Add filters in statistics view 2026-05-10 00:48:21 +02:00
Ettore
ff097b31d1 Add QR Code for Keypasses 2026-05-10 00:41:36 +02:00
13 changed files with 599 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">AZ + 09</option>
<option value="alpha">AZ only</option>
<option value="numeric">09 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 &amp; time</label> <label for="kp-expires">Expiry date &amp; 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>

View File

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

View File

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

View File

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