Initial release — Salus by Stranto v1.6.1.0

FastAPI/Jinja2 web app for viewing and rebooting TP-Link Omada APs
across all sites. Authentik OIDC auth, SQLite audit log, Docker deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:36:02 +02:00
commit 284924e86d
17 changed files with 1646 additions and 0 deletions

0
app/__init__.py Normal file
View File

118
app/auth.py Normal file
View File

@@ -0,0 +1,118 @@
import json
import base64
import secrets
import os
import logging
from typing import Optional
from urllib.parse import urlencode
import httpx
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
logger = logging.getLogger(__name__)
AUTHENTIK_ISSUER = os.getenv("AUTHENTIK_ISSUER", "").rstrip("/")
AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "")
AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "")
AUTHENTIK_REDIRECT_URI = os.getenv("AUTHENTIK_REDIRECT_URI", "http://localhost:8080/auth/callback")
SESSION_SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "changeme_please_set_in_env")
_oidc_config: Optional[dict] = None
async def get_oidc_config() -> dict:
global _oidc_config
if _oidc_config is None:
url = f"{AUTHENTIK_ISSUER}/.well-known/openid-configuration"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
_oidc_config = resp.json()
logger.info("OIDC config loaded from %s", url)
return _oidc_config
async def build_login_url(request: Request) -> str:
config = await get_oidc_config()
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
request.session["oauth_state"] = state
request.session["oauth_nonce"] = nonce
params = {
"response_type": "code",
"client_id": AUTHENTIK_CLIENT_ID,
"redirect_uri": AUTHENTIK_REDIRECT_URI,
"scope": "openid profile email",
"state": state,
"nonce": nonce,
}
return f"{config['authorization_endpoint']}?{urlencode(params)}"
def _decode_jwt_payload(token: str) -> dict:
"""Decode JWT payload without signature verification (Authentik is trusted)."""
try:
payload_b64 = token.split(".")[1]
padding = "=" * (4 - len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
except Exception:
return {}
async def exchange_code(request: Request) -> dict:
"""Handle OIDC callback: exchange code for tokens, extract user info."""
config = await get_oidc_config()
code = request.query_params.get("code")
state = request.query_params.get("state")
error = request.query_params.get("error")
if error:
raise HTTPException(status_code=400, detail=f"OAuth error: {error}")
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
if state != request.session.get("oauth_state"):
raise HTTPException(status_code=400, detail="OAuth state mismatch")
async with httpx.AsyncClient(timeout=15.0) as client:
token_resp = await client.post(
config["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": AUTHENTIK_REDIRECT_URI,
"client_id": AUTHENTIK_CLIENT_ID,
"client_secret": AUTHENTIK_CLIENT_SECRET,
},
)
token_resp.raise_for_status()
tokens = token_resp.json()
claims = _decode_jwt_payload(tokens.get("id_token", ""))
user = {
"username": claims.get("preferred_username") or claims.get("sub", "unknown"),
"email": claims.get("email", ""),
"name": claims.get("name", ""),
"sub": claims.get("sub", ""),
}
request.session["user"] = user
request.session.pop("oauth_state", None)
request.session.pop("oauth_nonce", None)
logger.info("User logged in: %s", user["username"])
return user
def get_current_user(request: Request) -> Optional[dict]:
return request.session.get("user")
def require_auth(request: Request) -> dict:
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=302,
headers={"Location": "/auth/login"},
)
return user

36
app/database.py Normal file
View File

@@ -0,0 +1,36 @@
import os
import datetime
from sqlalchemy import create_engine, Column, Integer, String, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class RebootLog(Base):
__tablename__ = "reboot_log"
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
username = Column(String, nullable=False)
user_email = Column(String, nullable=False)
ap_name = Column(String, nullable=False)
ap_mac = Column(String, nullable=False)
ap_ip = Column(String, nullable=True)
result = Column(String, nullable=False) # "success" | "error"
error_message = Column(String, nullable=True)
def init_db():
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

308
app/main.py Normal file
View File

@@ -0,0 +1,308 @@
import csv
import io
import datetime
import secrets
import logging
import os
from typing import Optional
# Load .env before any module-level os.getenv() calls in auth/omada/database
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy.orm import Session
from app.database import init_db, get_db, RebootLog
from app.auth import (
SESSION_SECRET_KEY,
build_login_url,
exchange_code,
get_current_user,
)
from app.omada import omada_client
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None)
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET_KEY,
max_age=86400,
same_site="lax",
https_only=False,
)
BASE_DIR = os.path.dirname(__file__)
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
@app.on_event("startup")
async def startup():
init_db()
logger.info("Database initialized")
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/debug/sites")
async def debug_sites():
"""Temporary: lists all accessible Omada sites."""
try:
sites = await omada_client.get_all_sites()
return {"sites": [{"name": k, "key": v} for k, v in sites.items()]}
except Exception as exc:
return {"error": str(exc)}
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _redirect_login():
return RedirectResponse("/auth/login", status_code=302)
def _get_user_or_redirect(request: Request):
"""Return user dict or a RedirectResponse. Callers must check the type."""
return get_current_user(request)
def _csrf_token(request: Request) -> str:
if "csrf_token" not in request.session:
request.session["csrf_token"] = secrets.token_hex(32)
return request.session["csrf_token"]
def _verify_csrf(request: Request, token: str):
expected = request.session.get("csrf_token")
if not expected or not secrets.compare_digest(expected, token):
raise HTTPException(status_code=403, detail="CSRF token invalid")
AUTH_DISABLED = os.getenv("AUTH_DISABLED", "false").strip().lower() == "true"
DEV_USER = {"username": "dev-user", "email": "dev@localhost", "name": "Dev User", "sub": "dev"}
# ---------------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------------
@app.get("/auth/login", response_class=HTMLResponse)
async def login(request: Request):
if AUTH_DISABLED:
request.session["user"] = DEV_USER
return RedirectResponse("/", status_code=302)
if get_current_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/auth/login/start")
async def login_start(request: Request):
if AUTH_DISABLED:
request.session["user"] = DEV_USER
return RedirectResponse("/", status_code=302)
url = await build_login_url(request)
return RedirectResponse(url, status_code=302)
@app.get("/auth/callback")
async def callback(request: Request):
await exchange_code(request)
return RedirectResponse("/", status_code=302)
@app.get("/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/auth/login", status_code=302)
# ---------------------------------------------------------------------------
# Main pages
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
user = get_current_user(request)
if not user:
return _redirect_login()
error: Optional[str] = None
aps: list = []
try:
aps = await omada_client.get_aps()
except Exception as exc:
logger.error("Failed to fetch APs: %s", exc)
error = str(exc)
csrf = _csrf_token(request)
return templates.TemplateResponse("index.html", {
"request": request,
"user": user,
"aps": aps,
"error": error,
"csrf_token": csrf,
})
@app.get("/audit", response_class=HTMLResponse)
async def audit_page(
request: Request,
db: Session = Depends(get_db),
username: str = "",
ap_name: str = "",
):
user = get_current_user(request)
if not user:
return _redirect_login()
query = db.query(RebootLog).order_by(RebootLog.timestamp.desc())
if username.strip():
query = query.filter(RebootLog.username.icontains(username.strip()))
if ap_name.strip():
query = query.filter(RebootLog.ap_name.icontains(ap_name.strip()))
logs = query.all()
return templates.TemplateResponse("audit.html", {
"request": request,
"user": user,
"logs": logs,
"filter_username": username,
"filter_ap": ap_name,
})
@app.get("/audit/export")
async def export_csv(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
return _redirect_login()
logs = db.query(RebootLog).order_by(RebootLog.timestamp.desc()).all()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["Timestamp (UTC)", "Username", "Email", "AP Name", "MAC", "IP", "Result", "Error"])
for log in logs:
writer.writerow([
log.timestamp.isoformat(timespec="seconds"),
log.username,
log.user_email,
log.ap_name,
log.ap_mac,
log.ap_ip or "",
log.result,
log.error_message or "",
])
filename = f"audit_log_{datetime.date.today().isoformat()}.csv"
return StreamingResponse(
io.BytesIO(buf.getvalue().encode("utf-8-sig")),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@app.post("/api/reboot")
async def api_reboot(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
body = await request.json()
_verify_csrf(request, body.get("csrf_token", ""))
mac: str = body.get("mac", "").strip()
ap_name: str = body.get("name", "")
ap_ip: str = body.get("ip", "")
site_key: str = body.get("site_key", "")
if not mac:
raise HTTPException(status_code=400, detail="MAC address required")
result = "success"
error_msg: Optional[str] = None
try:
await omada_client.reboot_ap(mac, site_key)
logger.info("Reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"])
except Exception as exc:
result = "error"
error_msg = str(exc)
logger.error("Reboot failed for AP %s (%s): %s", ap_name, mac, exc)
db.add(RebootLog(
timestamp=datetime.datetime.utcnow(),
username=user["username"],
user_email=user["email"],
ap_name=ap_name,
ap_mac=mac,
ap_ip=ap_ip,
result=result,
error_message=error_msg,
))
db.commit()
if result == "error":
raise HTTPException(status_code=502, detail=error_msg)
return {"status": "ok", "mac": mac}
@app.post("/api/reboot-bulk")
async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
body = await request.json()
_verify_csrf(request, body.get("csrf_token", ""))
aps: list = body.get("aps", [])
if not aps:
raise HTTPException(status_code=400, detail="No APs specified")
results = []
for ap in aps:
mac = ap.get("mac", "").strip()
ap_name = ap.get("name", "")
ap_ip = ap.get("ip", "")
site_key = ap.get("site_key", "")
result = "success"
error_msg = None
try:
await omada_client.reboot_ap(mac, site_key)
logger.info("Bulk reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"])
except Exception as exc:
result = "error"
error_msg = str(exc)
logger.error("Bulk reboot failed for AP %s (%s): %s", ap_name, mac, exc)
db.add(RebootLog(
timestamp=datetime.datetime.utcnow(),
username=user["username"],
user_email=user["email"],
ap_name=ap_name,
ap_mac=mac,
ap_ip=ap_ip,
result=result,
error_message=error_msg,
))
results.append({"mac": mac, "name": ap_name, "result": result, "error": error_msg})
db.commit()
return {"results": results}

214
app/omada.py Normal file
View File

@@ -0,0 +1,214 @@
import os
import time
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
OMADA_BASE_URL = os.getenv("OMADA_BASE_URL", "https://192.168.1.1:8043").rstrip("/")
OMADA_USERNAME = os.getenv("OMADA_USERNAME", "")
OMADA_PASSWORD = os.getenv("OMADA_PASSWORD", "")
OMADA_VERIFY_SSL = os.getenv("OMADA_VERIFY_SSL", "true").strip().lower() != "false"
OMADA_CLIENT_ID = os.getenv("OMADA_CLIENT_ID", "")
OMADA_CLIENT_SECRET = os.getenv("OMADA_CLIENT_SECRET", "")
class OmadaClient:
def __init__(self):
self._client: Optional[httpx.AsyncClient] = None
self._omadac_id: Optional[str] = None
self._token: Optional[str] = None
# {site_name: site_key}
self._sites: Optional[dict[str, str]] = None
# OpenAPI OAuth2
self._openapi_client: Optional[httpx.AsyncClient] = None
self._openapi_token: Optional[str] = None
self._openapi_token_expires: float = 0.0
def _get_client(self) -> httpx.AsyncClient:
"""Session client — carries web-login cookies for the v2 API."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
verify=OMADA_VERIFY_SSL,
timeout=30.0,
follow_redirects=True,
)
return self._client
def _get_openapi_client(self) -> httpx.AsyncClient:
"""Cookie-free client for OpenAPI calls — session cookies must not bleed in."""
if self._openapi_client is None or self._openapi_client.is_closed:
self._openapi_client = httpx.AsyncClient(
verify=OMADA_VERIFY_SSL,
timeout=30.0,
follow_redirects=True,
cookies={},
)
return self._openapi_client
def _auth_headers(self) -> dict:
return {"Csrf-Token": self._token} if self._token else {}
async def _fetch_omadac_id(self) -> str:
resp = await self._get_client().get(f"{OMADA_BASE_URL}/api/info")
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", -1) != 0:
raise RuntimeError(f"Omada /api/info error: {data.get('msg')}")
self._omadac_id = data["result"]["omadacId"]
logger.info("Omada controller ID: %s", self._omadac_id)
return self._omadac_id
async def _login(self) -> str:
if not self._omadac_id:
await self._fetch_omadac_id()
resp = await self._get_client().post(
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/login",
json={"username": OMADA_USERNAME, "password": OMADA_PASSWORD},
)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", -1) != 0:
raise RuntimeError(f"Omada login failed: {data.get('msg')}")
self._token = data["result"]["token"]
logger.info("Omada web-API login successful")
return self._token
async def _fetch_sites(self) -> dict[str, str]:
"""Return {site_name: site_key} for all sites accessible to this user."""
resp = await self._get_client().get(
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/users/current",
headers=self._auth_headers(),
)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", -1) != 0:
raise RuntimeError(f"Omada users/current error: {data.get('msg')}")
raw = data.get("result", {}).get("privilege", {}).get("sites", [])
self._sites = {s["name"]: s["key"] for s in raw}
logger.info("Loaded %d Omada sites", len(self._sites))
return self._sites
async def _ensure_ready(self):
if not self._token:
await self._login()
if self._sites is None:
await self._fetch_sites()
async def _request_with_retry(self, method: str, url: str, **kwargs):
client = self._get_client()
resp = await client.request(method, url, headers=self._auth_headers(), **kwargs)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", 0) in (-1006, -1003, -30109):
self._token = None
self._sites = None
await self._login()
resp = await client.request(method, url, headers=self._auth_headers(), **kwargs)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", 0) != 0:
raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}")
return data
# ── OpenAPI OAuth2 (commands) ──────────────────────────────────────────────
async def _get_openapi_token(self) -> str:
if self._openapi_token and time.time() < self._openapi_token_expires - 60:
return self._openapi_token
if not self._omadac_id:
await self._fetch_omadac_id()
resp = await self._get_openapi_client().post(
f"{OMADA_BASE_URL}/openapi/authorize/token",
data={
"grantType": "client_credentials",
"clientId": OMADA_CLIENT_ID,
"clientSecret": OMADA_CLIENT_SECRET,
},
)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", -1) != 0:
raise RuntimeError(f"Omada OpenAPI token error: {data.get('msg')}")
self._openapi_token = data["result"]["accessToken"]
self._openapi_token_expires = time.time() + data["result"].get("expiresIn", 3600)
logger.info("Omada OpenAPI token acquired")
return self._openapi_token
async def _openapi_post(self, path: str, body: dict) -> dict:
token = await self._get_openapi_token()
resp = await self._get_openapi_client().post(
f"{OMADA_BASE_URL}/openapi/v1/{self._omadac_id}/{path}",
headers={"Authorization": f"AccessToken={token}"},
json=body,
)
resp.raise_for_status()
data = resp.json()
if data.get("errorCode", 0) != 0:
raise RuntimeError(f"Omada OpenAPI error {data.get('errorCode')}: {data.get('msg')}")
return data
# ── Public methods ─────────────────────────────────────────────────────────
async def get_all_sites(self) -> dict[str, str]:
await self._ensure_ready()
return self._sites
async def get_aps(self) -> list[dict]:
"""Fetch APs from every accessible site and return a combined list."""
await self._ensure_ready()
all_aps = []
for site_name, site_key in self._sites.items():
try:
data = await self._request_with_retry(
"GET",
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices",
)
devices = data.get("result") or []
for d in devices:
if d.get("type") == "ap":
d["_site_name"] = site_name
d["_site_key"] = site_key
all_aps.append(d)
except Exception as exc:
logger.warning("Failed to fetch devices for site '%s': %s", site_name, exc)
logger.info("Fetched %d APs across %d sites", len(all_aps), len(self._sites))
return all_aps
async def _get_ap_uptime(self, mac: str, site_key: str) -> int:
"""Return current uptimeLong (seconds) for a specific AP."""
data = await self._request_with_retry(
"GET",
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices",
)
for device in (data.get("result") or []):
if device.get("mac") == mac and device.get("type") == "ap":
return int(device.get("uptimeLong", 0))
raise RuntimeError(f"AP {mac} not found in site {site_key}")
async def reboot_ap(self, mac: str, site_key: str, min_uptime: int = 300) -> dict:
await self._ensure_ready()
uptime = await self._get_ap_uptime(mac, site_key)
if uptime < min_uptime:
mins = uptime // 60
secs = uptime % 60
raise RuntimeError(
f"AP has only been online for {mins}m {secs}s. "
f"A minimum uptime of {min_uptime // 60} minutes is required before rebooting."
)
resp = await self._get_client().post(
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/cmd/devices/{mac}/reboot",
headers=self._auth_headers(),
json={},
)
resp.raise_for_status()
data = resp.json()
# -39009 = "Rebooting... Please wait." — Omada's success code for this command
if data.get("errorCode", 0) not in (0, -39009):
raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}")
return data
omada_client = OmadaClient()

0
app/static/.gitkeep Normal file
View File

132
app/templates/audit.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Audit Log Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
<p class="text-sm text-gray-500 mt-0.5">All reboot actions · {{ logs|length }} record{{ 's' if logs|length != 1 }}</p>
</div>
<a href="/audit/export"
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 border border-blue-500 text-white transition-colors self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export CSV
</a>
</div>
<!-- Filters -->
<form method="get" class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<input type="text" name="username" value="{{ filter_username }}"
placeholder="Filter by username…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0"/>
</svg>
<input type="text" name="ap_name" value="{{ filter_ap }}"
placeholder="Filter by AP name…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 transition-colors">
Filter
</button>
{% if filter_username or filter_ap %}
<a href="/audit"
class="px-4 py-2 text-sm rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 text-gray-500 transition-colors">
Clear
</a>
{% endif %}
</div>
</form>
{% if logs %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Timestamp (UTC)</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">User</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">AP Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Result</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3 text-gray-500 font-mono text-xs whitespace-nowrap">
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td class="px-4 py-3">
<div class="text-gray-900 text-sm font-medium">{{ log.username }}</div>
<div class="text-gray-400 text-xs">{{ log.user_email }}</div>
</td>
<td class="px-4 py-3 text-gray-900 font-medium">{{ log.ap_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_mac }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_ip or '—' }}</td>
<td class="px-4 py-3">
{% if log.result == 'success' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
Success
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
Error
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-400 text-xs max-w-xs truncate" title="{{ log.error_message or '' }}">
{{ log.error_message or '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-lg font-medium">No log entries found</p>
{% if filter_username or filter_ap %}
<p class="text-sm mt-1">Try adjusting your filters.</p>
{% else %}
<p class="text-sm mt-1">Reboot actions will appear here.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

103
app/templates/base.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Salus by Stranto{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
.toast { animation: slide-in 0.3s ease-out; }
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
</head>
<body class="h-full text-gray-900 flex flex-col">
<!-- Nav -->
<nav class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-8">
<a href="/" class="flex items-center gap-2 font-bold text-lg text-gray-900 hover:text-blue-600 transition-colors">
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Salus <span class="font-normal text-gray-400 text-sm">by Stranto</span>
</a>
<div class="hidden sm:flex gap-1">
<a href="/"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
{% if request.url.path == '/' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
Access Points
</a>
<a href="/audit"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
{% if request.url.path == '/audit' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
Audit Log
</a>
</div>
</div>
{% if user %}
<div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-gray-500">
<span class="text-gray-900 font-medium">{{ user.username }}</span>
</span>
<a href="/auth/logout"
class="px-3 py-1.5 text-sm rounded-md bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-700 border border-gray-200 transition-colors">
Logout
</a>
</div>
{% endif %}
</div>
</div>
</nav>
<!-- Page content -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="border-t border-gray-200 bg-white mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between text-xs text-gray-400">
<span>Version 1.6.1.0</span>
<a href="https://www.stranto.com/" target="_blank" rel="noopener noreferrer"
class="hover:text-blue-500 transition-colors">stranto.com</a>
</div>
</footer>
<!-- Toast container -->
<div id="toast-container" class="fixed bottom-4 right-4 flex flex-col gap-2 z-50 pointer-events-none"></div>
<script>
function showToast(msg, type = 'success') {
const container = document.getElementById('toast-container');
const colors = type === 'success'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800';
const el = document.createElement('div');
el.className = `toast pointer-events-auto px-4 py-3 rounded-lg border shadow text-sm max-w-xs ${colors}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => { el.remove(); }, 4000);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

321
app/templates/index.html Normal file
View File

@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% block title %}Access Points Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header row -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Access Points</h1>
<p class="text-sm text-gray-500 mt-0.5">Live from Omada Controller · <span id="visible-count">{{ aps|length }}</span> of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites</p>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<input id="filter-name" type="text" placeholder="Filter by name or site…"
class="pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-52" />
</div>
<button id="btn-refresh"
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
<svg id="refresh-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
<button id="btn-reboot-selected" disabled
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 disabled:opacity-40 disabled:cursor-not-allowed border border-red-500 text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Reboot selected (<span id="sel-count">0</span>)
</button>
</div>
</div>
{% if error %}
<div class="flex items-start gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<p class="font-semibold">Failed to connect to Omada Controller</p>
<p class="mt-0.5 text-red-600">{{ error }}</p>
</div>
</div>
{% endif %}
{% if aps %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-10">
<input type="checkbox" id="chk-all"
class="rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer" />
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Site</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Model</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Status</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Uptime</th>
<th class="px-4 py-3 text-right font-semibold text-gray-500 uppercase tracking-wide text-xs">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white" id="ap-table-body">
{% for ap in aps %}
{% set online = ap.get('statusCategory', 0) == 1 %}
{% set mac = ap.get('mac', '') %}
{% set name = ap.get('name', 'Unknown') %}
{% set ip = ap.get('ip', '') %}
{% set site_name = ap.get('_site_name', '') %}
{% set site_key = ap.get('_site_key', '') %}
{% set uptime_secs = ap.get('uptimeLong', 0) | int %}
{% set reboot_allowed = online and uptime_secs >= 300 %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<input type="checkbox" class="ap-checkbox rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer
{% if not reboot_allowed %}opacity-40 cursor-not-allowed{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
{% endif %} />
</td>
<td class="px-4 py-3 font-medium text-gray-900">{{ name }}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ site_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ ip or '—' }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ mac or '—' }}</td>
<td class="px-4 py-3 text-gray-500">{{ ap.get('model', '—') }}</td>
<td class="px-4 py-3">
{% if online %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
Online
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
Offline
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{% set up = ap.get('uptimeLong', 0) | int %}
{% if up %}
{{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-right">
<button
class="btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium transition-colors
{% if reboot_allowed %}bg-red-600 hover:bg-red-500 border border-red-500 text-white cursor-pointer
{% else %}bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low ({{ uptime_secs // 60 }}m {{ uptime_secs % 60 }}s) — minimum 5 minutes required{% endif %}"
{% endif %}>
Reboot
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif not error %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
</svg>
<p class="text-lg font-medium">No access points found</p>
<p class="text-sm mt-1">Check your Omada site name configuration.</p>
</div>
{% endif %}
</div>
<!-- Confirmation Modal -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-md p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Confirm Reboot</h2>
</div>
<p class="text-gray-600 text-sm mb-6" id="modal-message">Are you sure?</p>
<div class="flex gap-3 justify-end">
<button id="modal-cancel"
class="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 transition-colors">
Cancel
</button>
<button id="modal-confirm"
class="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 border border-red-500 text-white font-semibold transition-colors">
Reboot
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CSRF_TOKEN = {{ csrf_token | tojson }};
// ── Name / site filter ────────────────────────────────────────────────────
const filterInput = document.getElementById('filter-name');
const visibleCount = document.getElementById('visible-count');
const allRows = [...document.querySelectorAll('#ap-table-body tr')];
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase().trim();
let shown = 0;
allRows.forEach(row => {
const name = (row.querySelector('td:nth-child(2)')?.textContent || '').toLowerCase();
const site = (row.querySelector('td:nth-child(3)')?.textContent || '').toLowerCase();
const match = !q || name.includes(q) || site.includes(q);
row.style.display = match ? '' : 'none';
if (match) shown++;
});
visibleCount.textContent = shown;
updateBulkButton();
});
// ── Refresh ────────────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', () => {
const icon = document.getElementById('refresh-icon');
icon.classList.add('animate-spin');
window.location.reload();
});
// ── Checkbox logic ─────────────────────────────────────────────────────────
const chkAll = document.getElementById('chk-all');
const btnRebootSel = document.getElementById('btn-reboot-selected');
const selCount = document.getElementById('sel-count');
function updateBulkButton() {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')]
.filter(cb => cb.closest('tr').style.display !== 'none');
selCount.textContent = checked.length;
btnRebootSel.disabled = checked.length === 0;
}
chkAll?.addEventListener('change', () => {
document.querySelectorAll('.ap-checkbox:not(:disabled)').forEach(cb => {
cb.checked = chkAll.checked;
});
updateBulkButton();
});
document.querySelectorAll('.ap-checkbox').forEach(cb => {
cb.addEventListener('change', updateBulkButton);
});
// ── Modal ──────────────────────────────────────────────────────────────────
const modal = document.getElementById('modal');
const modalMsg = document.getElementById('modal-message');
const modalConfirm = document.getElementById('modal-confirm');
let pendingAction = null;
function showModal(message, onConfirm) {
modalMsg.textContent = message;
pendingAction = onConfirm;
modal.classList.remove('hidden');
}
function hideModal() {
modal.classList.add('hidden');
pendingAction = null;
}
document.getElementById('modal-backdrop').addEventListener('click', hideModal);
document.getElementById('modal-cancel').addEventListener('click', hideModal);
modalConfirm.addEventListener('click', () => {
if (pendingAction) pendingAction();
hideModal();
});
// ── Single reboot ──────────────────────────────────────────────────────────
document.querySelectorAll('.btn-reboot').forEach(btn => {
btn.addEventListener('click', () => {
const mac = btn.dataset.mac;
const name = btn.dataset.name;
const ip = btn.dataset.ip;
const site_key = btn.dataset.siteKey;
showModal(`Reboot AP "${name}"?\nThis will disconnect all clients connected to this AP.`, async () => {
btn.disabled = true;
btn.textContent = 'Rebooting…';
try {
const res = await fetch('/api/reboot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac, name, ip, site_key, csrf_token: CSRF_TOKEN }),
});
if (res.ok) {
showToast(`Reboot command sent to "${name}"`, 'success');
} else {
const err = await res.json();
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
} catch (e) {
showToast('Network error', 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
});
});
});
// ── Bulk reboot ────────────────────────────────────────────────────────────
btnRebootSel?.addEventListener('click', () => {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')];
const aps = checked.map(cb => ({ mac: cb.dataset.mac, name: cb.dataset.name, ip: cb.dataset.ip, site_key: cb.dataset.siteKey }));
const names = aps.map(a => a.name).join(', ');
showModal(`Reboot ${aps.length} AP${aps.length > 1 ? 's' : ''}?\n${names}`, async () => {
btnRebootSel.disabled = true;
btnRebootSel.querySelector('span').textContent = '0';
try {
const res = await fetch('/api/reboot-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aps, csrf_token: CSRF_TOKEN }),
});
const data = await res.json();
const errors = (data.results || []).filter(r => r.result === 'error');
if (errors.length === 0) {
showToast(`Reboot sent to ${aps.length} AP${aps.length > 1 ? 's' : ''}`, 'success');
} else {
showToast(`${errors.length} AP(s) failed to reboot`, 'error');
}
document.querySelectorAll('.ap-checkbox').forEach(cb => { cb.checked = false; });
if (chkAll) chkAll.checked = false;
updateBulkButton();
} catch (e) {
showToast('Network error', 'error');
updateBulkButton();
}
});
});
</script>
{% endblock %}

38
app/templates/login.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login Salus by Stranto</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="h-full flex items-center justify-center text-gray-900">
<div class="w-full max-w-sm">
<div class="bg-white border border-gray-200 rounded-2xl shadow-lg p-8 flex flex-col items-center gap-6">
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 rounded-full bg-blue-600 flex items-center justify-center shadow">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="text-xl font-bold text-gray-900">Salus <span class="font-normal text-gray-400 text-base">by Stranto</span></h1>
<p class="text-sm text-gray-500">Sign in to manage your access points</p>
</div>
<a href="/auth/login/start"
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold transition-colors shadow">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 3H19a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
</svg>
Sign in with Authentik
</a>
<p class="text-xs text-gray-400 text-center">
Access is restricted to authorized users only.
</p>
</div>
</div>
</body>
</html>