🚧 WIP
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build context is the repo root (see docker-compose.yml) so shared/ is
|
||||
# copyable; shared/ holds the access-control model used by api + webui.
|
||||
COPY webui/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY shared /app/shared
|
||||
COPY webui /app/webui
|
||||
|
||||
WORKDIR /app/webui
|
||||
|
||||
# shared/ is installed at /app/shared; make it importable from /app/webui.
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
RUN useradd --system --no-create-home webui
|
||||
USER webui
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Authentication for the web UI: password hashing, sessions, throttling.
|
||||
|
||||
- Passwords are hashed with argon2id (passlib).
|
||||
- Session cookies carry a random token; only its SHA-256 is stored.
|
||||
- Sessions are persisted in Postgres, so a login in the web UI is also
|
||||
valid against the API (shared/access.py resolves the same table).
|
||||
- Login is throttled per source IP (5 attempts per 15 minutes, then a
|
||||
5-minute lockout) to blunt credential stuffing against the exposed
|
||||
login form.
|
||||
- On startup, an admin account is created only if the users table is
|
||||
empty: username/password from ADMIN_USERNAME/ADMIN_PASSWORD if set,
|
||||
otherwise the documented default admin/admin. The default account is
|
||||
flagged must_change_password so the UI forces a change on first login.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from shared.access import COOKIE_NAME, hash_token
|
||||
|
||||
# Fixed session lifetime; a sliding window would need a write per request,
|
||||
# so sessions simply expire after this span (users log in again).
|
||||
SESSION_TTL = timedelta(hours=12)
|
||||
|
||||
LOCKOUT_ATTEMPTS = 5
|
||||
LOCKOUT_WINDOW = timedelta(minutes=15)
|
||||
LOCKOUT_DURATION = timedelta(minutes=5)
|
||||
|
||||
_pwd: Optional[CryptContext] = None
|
||||
|
||||
|
||||
def _crypt() -> CryptContext:
|
||||
"""Lazy argon2 init: passlib's runtime self-check is slow (~1s)."""
|
||||
global _pwd
|
||||
if _pwd is None:
|
||||
_pwd = CryptContext(schemes=["argon2"])
|
||||
return _pwd
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _crypt().hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _crypt().verify(password, password_hash)
|
||||
except Exception: # noqa: BLE001 - malformed hash must not 500 the login
|
||||
return False
|
||||
|
||||
|
||||
def _interval(td: timedelta) -> str:
|
||||
return f"{int(td.total_seconds())} seconds"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_session(conn: psycopg.Connection[Any], user_id: int) -> tuple[str, str]:
|
||||
"""Create a session; returns (cookie_token, csrf_token)."""
|
||||
cookie_token = secrets.token_urlsafe(32)
|
||||
csrf_token = secrets.token_urlsafe(32)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)"
|
||||
" VALUES (%s, %s, %s, now() + %s::interval)",
|
||||
(hash_token(cookie_token), user_id, csrf_token, _interval(SESSION_TTL)),
|
||||
)
|
||||
return cookie_token, csrf_token
|
||||
|
||||
|
||||
def delete_session(conn: psycopg.Connection[Any], token: str) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM sessions WHERE token_hash = %s", (hash_token(token),))
|
||||
|
||||
|
||||
def bootstrap_admin(conn: psycopg.Connection[Any]) -> None:
|
||||
"""Create the initial admin from ENV or default admin/admin.
|
||||
|
||||
Runs only when the users table is completely empty — restarting the
|
||||
stack must never reset or resurrect an administrator.
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
count = cur.execute("SELECT count(*) FROM users").fetchone()[0]
|
||||
if count and count > 0:
|
||||
return
|
||||
username = os.getenv("ADMIN_USERNAME", "admin")
|
||||
password = os.getenv("ADMIN_PASSWORD", "admin")
|
||||
from_env = bool(os.getenv("ADMIN_PASSWORD"))
|
||||
# A default (non-ENV) password must always be changed at first
|
||||
# login; an ENV-provided one is treated as provisioned.
|
||||
must_change = not from_env or username == "admin"
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, role, must_change_password)"
|
||||
" VALUES (%s, %s, 'admin', %s)",
|
||||
(username, hash_password(password), must_change),
|
||||
)
|
||||
print(
|
||||
f"Bootstrapped initial admin '{username}'"
|
||||
+ (" (default credentials; change on first login)" if must_change else "")
|
||||
)
|
||||
|
||||
|
||||
def _attempt_key(ip: str) -> str:
|
||||
# login_attempts.ip is TEXT PRIMARY KEY; keep long IPv6 strings sane.
|
||||
return ip[:64] if ip else "unknown"
|
||||
|
||||
|
||||
def check_throttle(conn: psycopg.Connection[Any], ip: str) -> tuple[bool, int]:
|
||||
"""Return (allowed, attempts). Locks out after LOCKOUT_ATTEMPTS tries."""
|
||||
key = _attempt_key(ip)
|
||||
with conn.cursor() as cur:
|
||||
row = cur.execute(
|
||||
"SELECT attempts, last_seen FROM login_attempts WHERE ip = %s", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return True, 0
|
||||
attempts, last_seen = row
|
||||
if attempts >= LOCKOUT_ATTEMPTS:
|
||||
if last_seen < _utcnow() - LOCKOUT_DURATION:
|
||||
# Lockout expired: start a fresh window.
|
||||
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (key,))
|
||||
return True, 0
|
||||
return False, attempts
|
||||
return True, attempts
|
||||
|
||||
|
||||
def record_failed_login(conn: psycopg.Connection[Any], ip: str) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO login_attempts (ip, attempts, last_seen)
|
||||
VALUES (%s, 1, now())
|
||||
ON CONFLICT (ip) DO UPDATE
|
||||
SET attempts = CASE
|
||||
WHEN login_attempts.last_seen < now() - %s::interval
|
||||
THEN 1
|
||||
ELSE login_attempts.attempts + 1
|
||||
END,
|
||||
last_seen = now()
|
||||
""",
|
||||
(_attempt_key(ip), _interval(LOCKOUT_WINDOW)),
|
||||
)
|
||||
|
||||
|
||||
def clear_failed_logins(conn: psycopg.Connection[Any], ip: str) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (_attempt_key(ip),))
|
||||
|
||||
|
||||
def set_cookie(response: Any, token: str) -> None:
|
||||
"""Attach the session cookie (HttpOnly; the webui is same-origin)."""
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
max_age=int(SESSION_TTL.total_seconds()),
|
||||
)
|
||||
|
||||
|
||||
def clear_cookie(response: Any) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
def constant_time_eq(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
"""Web UI for access control: users, groups, topic access rules.
|
||||
|
||||
Serves the login page, the admin pages (users, groups), and a telemetry
|
||||
browser so the effect of access rules can be verified directly. Sessions
|
||||
are shared with the API via the mqtt_session cookie, so a web login also
|
||||
authenticates API requests from the same browser.
|
||||
|
||||
User/group management is admin-only. Access-rule semantics (globs, deny
|
||||
wins over allow, user rules override group rules) live in shared/access.py.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import auth
|
||||
from shared.access import (
|
||||
COOKIE_NAME,
|
||||
Identity,
|
||||
escape_like,
|
||||
hash_token,
|
||||
load_identity,
|
||||
topic_conditions,
|
||||
)
|
||||
|
||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
|
||||
_DictConnection = psycopg.Connection[dict[str, Any]]
|
||||
pool: Optional[_DictConnection] = None
|
||||
|
||||
|
||||
def _connect_kwargs() -> dict[str, Any]:
|
||||
return {
|
||||
"host": os.getenv("DB_HOST", "timescaledb"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "telemetry"),
|
||||
"user": os.getenv("DB_USER", "ingester"),
|
||||
"password": os.getenv("DB_PASSWORD", "changeme"),
|
||||
"row_factory": dict_row,
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global pool
|
||||
# Single long-lived autocommit connection; the web UI's load is tiny
|
||||
# compared to the API's, so a pool would be overkill.
|
||||
conn = psycopg.connect(**_connect_kwargs())
|
||||
pool = conn
|
||||
try:
|
||||
# First run: create the initial admin (ENV or default admin/admin).
|
||||
auth.bootstrap_admin(conn)
|
||||
yield
|
||||
finally:
|
||||
pool = None
|
||||
conn.close()
|
||||
|
||||
|
||||
app = FastAPI(title="MQTT Ingester Web UI", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||
|
||||
|
||||
def get_conn() -> _DictConnection:
|
||||
if pool is None:
|
||||
raise RuntimeError("Database connection not initialized")
|
||||
return pool
|
||||
|
||||
|
||||
def current_identity(request: Request) -> Optional[Identity]:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return load_identity(get_conn(), hash_token(token))
|
||||
|
||||
|
||||
def require_login(request: Request) -> Identity:
|
||||
identity = current_identity(request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
# A default/admin-set password must be changed before anything else.
|
||||
if identity.must_change_password and request.url.path not in (
|
||||
"/password",
|
||||
"/logout",
|
||||
):
|
||||
raise HTTPException(status_code=303, headers={"Location": "/password"})
|
||||
return identity
|
||||
|
||||
|
||||
def require_admin(request: Request) -> Identity:
|
||||
identity = require_login(request)
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return identity
|
||||
|
||||
|
||||
async def require_csrf(request: Request, identity: Identity) -> None:
|
||||
"""Validate the CSRF token for a mutating (form) request."""
|
||||
form = await request.form()
|
||||
submitted = form.get("csrf_token", "")
|
||||
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||
submitted, identity.csrf_token
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Invalid CSRF token")
|
||||
|
||||
|
||||
def _telemetry_rows(identity: Identity, topic: Optional[str]) -> list[dict[str, Any]]:
|
||||
"""Recent telemetry filtered by the identity's access rules in SQL."""
|
||||
conditions, param_groups = topic_conditions(identity)
|
||||
params: list[Any] = []
|
||||
if topic:
|
||||
conditions.append(sql.SQL("topic LIKE %s"))
|
||||
params.append(escape_like(topic))
|
||||
params.extend(p for group in param_groups for p in group)
|
||||
query = sql.SQL("SELECT time, topic, payload FROM telemetry")
|
||||
if conditions:
|
||||
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
|
||||
query += sql.SQL(" ORDER BY time DESC LIMIT 100")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
# -- Login / logout ---------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
if current_identity(request) is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return RedirectResponse("/data", status_code=303)
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: Optional[str] = None):
|
||||
if current_identity(request) is not None:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"error": error})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||
conn = get_conn()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
allowed, attempts = auth.check_throttle(conn, ip)
|
||||
if not allowed:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"error": "Too many failed attempts; try again in a few minutes."},
|
||||
status_code=429,
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
row = cur.execute(
|
||||
"SELECT id, password_hash FROM users"
|
||||
" WHERE username = %s AND disabled_at IS NULL",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row is None or not auth.verify_password(password, row[1]):
|
||||
auth.record_failed_login(conn, ip)
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"error": "Invalid username or password"}, status_code=401
|
||||
)
|
||||
auth.clear_failed_logins(conn, ip)
|
||||
cookie_token, _csrf = auth.create_session(conn, row[0])
|
||||
resp = RedirectResponse("/data", status_code=303)
|
||||
auth.set_cookie(resp, cookie_token)
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout(request: Request):
|
||||
identity = current_identity(request)
|
||||
if identity is not None:
|
||||
await require_csrf(request, identity)
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
if token:
|
||||
auth.delete_session(get_conn(), token)
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
auth.clear_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
# -- Account (self-service) --------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/password", response_class=HTMLResponse)
|
||||
async def password_page(request: Request, error: Optional[str] = None):
|
||||
identity = require_login(request)
|
||||
return templates.TemplateResponse(
|
||||
request, "password.html", {"identity": identity, "error": error}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/password")
|
||||
async def password_change(
|
||||
request: Request,
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...),
|
||||
):
|
||||
identity = require_login(request)
|
||||
await require_csrf(request, identity)
|
||||
conn = get_conn()
|
||||
|
||||
def fail(message: str, status: int):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"password.html",
|
||||
{"identity": identity, "error": message},
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
row = cur.execute(
|
||||
"SELECT password_hash FROM users WHERE id = %s", (identity.user_id,)
|
||||
).fetchone()
|
||||
if row is None or not auth.verify_password(current_password, row[0]):
|
||||
return fail("Current password is incorrect", 401)
|
||||
if new_password != confirm_password:
|
||||
return fail("New passwords do not match", 400)
|
||||
if len(new_password) < 8:
|
||||
return fail("New password must be at least 8 characters", 400)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s, must_change_password = FALSE"
|
||||
" WHERE id = %s",
|
||||
(auth.hash_password(new_password), identity.user_id),
|
||||
)
|
||||
return RedirectResponse("/data", status_code=303)
|
||||
|
||||
|
||||
# -- Users (admin) -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
users = cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.role, u.disabled_at, u.must_change_password,
|
||||
coalesce(
|
||||
(SELECT array_agg(g.name ORDER BY g.name)
|
||||
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id), '{}') AS groups
|
||||
FROM users u ORDER BY u.username
|
||||
"""
|
||||
).fetchall()
|
||||
groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request, "users.html", {"identity": identity, "users": users, "groups": groups}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/users")
|
||||
async def create_user(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
role: str = Form("user"),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
conn = get_conn()
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="Username is required")
|
||||
if role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
with conn.cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, role) VALUES (%s, %s, %s)",
|
||||
(username, auth.hash_password(password), role),
|
||||
)
|
||||
except psycopg.errors.UniqueViolation:
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
if group_ids:
|
||||
user_id = cur.execute(
|
||||
"SELECT id FROM users WHERE username = %s", (username,)
|
||||
).fetchone()[0]
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(user_id, int(gid)) for gid in group_ids],
|
||||
)
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.get("/users/{user_id}", response_class=HTMLResponse)
|
||||
async def user_detail(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
user = cur.execute(
|
||||
"SELECT id, username, role, disabled_at, must_change_password"
|
||||
" FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
rules = cur.execute(
|
||||
"SELECT id, kind, pattern FROM access_rules WHERE user_id = %s"
|
||||
" ORDER BY kind, pattern",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
member_of = cur.execute(
|
||||
"SELECT g.id, g.name FROM groups g"
|
||||
" JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = %s",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
all_groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"user_detail.html",
|
||||
{
|
||||
"identity": identity,
|
||||
"user": user,
|
||||
"rules": rules,
|
||||
"member_of": member_of,
|
||||
"all_groups": all_groups,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/password")
|
||||
async def set_user_password(request: Request, user_id: int, password: str = Form(...)):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s, must_change_password = TRUE"
|
||||
" WHERE id = %s",
|
||||
(auth.hash_password(password), user_id),
|
||||
)
|
||||
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/groups")
|
||||
async def set_user_groups(
|
||||
request: Request, user_id: int, group_ids: list[str] = Form(default=[])
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
|
||||
if group_ids:
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(user_id, int(gid)) for gid in group_ids],
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/disable")
|
||||
async def disable_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET disabled_at = now() WHERE id = %s AND disabled_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/enable")
|
||||
async def enable_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("UPDATE users SET disabled_at = NULL WHERE id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/delete")
|
||||
async def delete_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if user_id == identity.user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/rules")
|
||||
async def add_user_rule(
|
||||
request: Request, user_id: int, kind: str = Form(...), pattern: str = Form(...)
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if kind not in ("allow", "deny"):
|
||||
raise HTTPException(status_code=400, detail="Invalid kind")
|
||||
pattern = pattern.strip()
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=400, detail="Pattern is required")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO access_rules (user_id, kind, pattern) VALUES (%s, %s, %s)",
|
||||
(user_id, kind, pattern),
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/rules/{rule_id}/delete")
|
||||
async def delete_user_rule(request: Request, user_id: int, rule_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM access_rules WHERE id = %s AND user_id = %s", (rule_id, user_id)
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
# -- Groups (admin) -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/groups", response_class=HTMLResponse)
|
||||
async def groups_page(request: Request):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
groups = cur.execute(
|
||||
"""
|
||||
SELECT g.id, g.name,
|
||||
(SELECT count(*) FROM user_groups ug WHERE ug.group_id = g.id)
|
||||
AS member_count,
|
||||
(SELECT count(*) FROM access_rules ar WHERE ar.group_id = g.id)
|
||||
AS rule_count
|
||||
FROM groups g ORDER BY g.name
|
||||
"""
|
||||
).fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request, "groups.html", {"identity": identity, "groups": groups}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/groups")
|
||||
async def create_group(request: Request, name: str = Form(...)):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Group name is required")
|
||||
with get_conn().cursor() as cur:
|
||||
try:
|
||||
cur.execute("INSERT INTO groups (name) VALUES (%s)", (name,))
|
||||
except psycopg.errors.UniqueViolation:
|
||||
raise HTTPException(status_code=409, detail="Group name already exists")
|
||||
return RedirectResponse("/groups", status_code=303)
|
||||
|
||||
|
||||
@app.get("/groups/{group_id}", response_class=HTMLResponse)
|
||||
async def group_detail(request: Request, group_id: int):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
group = cur.execute(
|
||||
"SELECT id, name FROM groups WHERE id = %s", (group_id,)
|
||||
).fetchone()
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
rules = cur.execute(
|
||||
"SELECT id, kind, pattern FROM access_rules WHERE group_id = %s"
|
||||
" ORDER BY kind, pattern",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
members = cur.execute(
|
||||
"SELECT u.id, u.username FROM users u"
|
||||
" JOIN user_groups ug ON ug.user_id = u.id WHERE ug.group_id = %s",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
all_users = cur.execute(
|
||||
"SELECT id, username FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"group_detail.html",
|
||||
{
|
||||
"identity": identity,
|
||||
"group": group,
|
||||
"rules": rules,
|
||||
"members": members,
|
||||
"all_users": all_users,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/rules")
|
||||
async def add_group_rule(
|
||||
request: Request, group_id: int, kind: str = Form(...), pattern: str = Form(...)
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if kind not in ("allow", "deny"):
|
||||
raise HTTPException(status_code=400, detail="Invalid kind")
|
||||
pattern = pattern.strip()
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=400, detail="Pattern is required")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO access_rules (group_id, kind, pattern) VALUES (%s, %s, %s)",
|
||||
(group_id, kind, pattern),
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/rules/{rule_id}/delete")
|
||||
async def delete_group_rule(request: Request, group_id: int, rule_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM access_rules WHERE id = %s AND group_id = %s",
|
||||
(rule_id, group_id),
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/members")
|
||||
async def set_group_members(
|
||||
request: Request, group_id: int, user_ids: list[str] = Form(default=[])
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM user_groups WHERE group_id = %s", (group_id,))
|
||||
if user_ids:
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(int(uid), group_id) for uid in user_ids],
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/delete")
|
||||
async def delete_group(request: Request, group_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM groups WHERE id = %s", (group_id,))
|
||||
return RedirectResponse("/groups", status_code=303)
|
||||
|
||||
|
||||
# -- Data browser -------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/data", response_class=HTMLResponse)
|
||||
async def data_page(request: Request, topic: Optional[str] = None):
|
||||
identity = require_login(request)
|
||||
rows = _telemetry_rows(identity, topic)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"data.html",
|
||||
{"identity": identity, "rows": rows, "topic": topic or ""},
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
psycopg[binary,pool]==3.2.3
|
||||
jinja2==3.1.5
|
||||
passlib[argon2]==1.7.4
|
||||
python-multipart==0.0.20
|
||||
@@ -0,0 +1,198 @@
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #1f2430;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1e40af;
|
||||
--danger: #dc2626;
|
||||
--ok: #16a34a;
|
||||
--warn: #d97706;
|
||||
--border: #e2e5ea;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #101828;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
nav a { color: #cbd5e1; text-decoration: none; padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
nav a:hover { color: #fff; }
|
||||
nav a.active { color: #fff; background: rgba(255, 255, 255, 0.12); }
|
||||
nav a.brand { font-weight: 700; color: #fff; margin-right: 0.5rem; }
|
||||
nav .spacer { flex: 1; }
|
||||
nav .user { color: #94a3b8; font-size: 0.9em; }
|
||||
form.inline { display: inline; }
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
|
||||
.banner {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #92400e;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.hint { color: var(--muted); font-size: 0.88em; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { background: #f9fafb; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.actions { display: flex; gap: 0.5rem; }
|
||||
td code, .inline-form code { font-size: 0.9em; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.75em;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.tag.allow, .tag.ok { background: #dcfce7; color: #166534; }
|
||||
.tag.deny, .tag.disabled { background: #fee2e2; color: #991b1b; }
|
||||
.tag.warn { background: #fef3c7; color: #92400e; }
|
||||
|
||||
form.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
form.stack label, form.stack fieldset legend { font-size: 0.85em; color: var(--muted); }
|
||||
form.stack input[type="text"], form.stack input[type="password"], form.stack input[type="search"],
|
||||
form.inline-form input[type="text"], form.filter input[type="search"] {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
form.stack select, form.inline-form select {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
form.inline-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
form.inline-form input[type="text"] { width: auto; flex: 1; min-width: 200px; margin-top: 0; }
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--accent-dark); }
|
||||
|
||||
button.link {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.85em;
|
||||
text-decoration: underline;
|
||||
}
|
||||
button.link:hover { background: none; color: var(--accent-dark); }
|
||||
button.link.danger { color: var(--danger); }
|
||||
button.link.danger:hover { color: #991b1b; }
|
||||
|
||||
form.filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
form.filter input[type="search"] { flex: 1; }
|
||||
|
||||
label.check { display: flex; gap: 0.5rem; align-items: center; font-size: 0.95em; }
|
||||
fieldset { border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
section { margin-top: 2rem; }
|
||||
|
||||
/* Login page */
|
||||
body.login-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #101828;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--card);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.login-card h1 { margin: 0; font-size: 1.2rem; }
|
||||
.login-card form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.login-card label { font-size: 0.85em; color: var(--muted); }
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MQTT Ingester{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a class="brand" href="/data">MQTT Ingester</a>
|
||||
<a href="/data" {% if nav == 'data' %}class="active"{% endif %}>Data</a>
|
||||
{% if identity and identity.is_admin %}
|
||||
<a href="/users" {% if nav == 'users' %}class="active"{% endif %}>Users</a>
|
||||
<a href="/groups" {% if nav == 'groups' %}class="active"{% endif %}>Groups</a>
|
||||
{% endif %}
|
||||
<span class="spacer"></span>
|
||||
{% if identity %}
|
||||
<span class="user">{{ identity.username }}{% if identity.is_admin %} (admin){% endif %}</span>
|
||||
<a href="/password">Password</a>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link">Log out</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% if identity and identity.must_change_password %}
|
||||
<div class="banner">Default password in use — please <a href="/password">change your password</a>.</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Data — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Recent telemetry</h1>
|
||||
<form method="get" action="/data" class="filter">
|
||||
<input type="search" name="topic" value="{{ topic }}" placeholder="Filter by topic, e.g. 11001.*">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
<p class="hint">Showing the 100 most recent measurements you have access to. Patterns follow the access rules: <code>*</code> matches any sequence.</p>
|
||||
{% if rows %}
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Topic</th><th>Payload</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.time }}</td>
|
||||
<td>{{ row.topic }}</td>
|
||||
<td><code>{{ row.payload | tojson }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No telemetry visible (nothing ingested yet, or your access rules match no topics).</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ group.name }} — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ group.name }}</h1>
|
||||
|
||||
<section>
|
||||
<h2>Access rules</h2>
|
||||
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Group rules combine with users' personal rules.</p>
|
||||
<table>
|
||||
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rules %}
|
||||
<tr>
|
||||
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
|
||||
<td><code>{{ r.pattern }}</code></td>
|
||||
<td>
|
||||
<form method="post" action="/groups/{{ group.id }}/rules/{{ r.id }}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/groups/{{ group.id }}/rules" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<select name="kind">
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
<input type="text" name="pattern" placeholder="e.g. 11001.*" required>
|
||||
<button type="submit">Add rule</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Members</h2>
|
||||
<form method="post" action="/groups/{{ group.id }}/members" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<fieldset>
|
||||
{% for u in all_users %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="user_ids" value="{{ u.id }}"
|
||||
{% if u.id in members | map(attribute='id') | list %}checked{% endif %}>
|
||||
{{ u.username }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<button type="submit">Save members</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Groups — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Groups</h1>
|
||||
<h2>Create group</h2>
|
||||
<form method="post" action="/groups" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<h2>Existing groups</h2>
|
||||
{% if groups %}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Members</th><th>Rules</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td><a href="/groups/{{ g.id }}">{{ g.name }}</a></td>
|
||||
<td>{{ g.member_count }}</td>
|
||||
<td>{{ g.rule_count }}</td>
|
||||
<td>
|
||||
<form method="post" action="/groups/{{ g.id }}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link danger">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No groups yet.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Log in — MQTT Ingester</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-card">
|
||||
<h1>MQTT Ingester</h1>
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>Username
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Password — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Change password</h1>
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
{% if identity.must_change_password %}
|
||||
<p class="banner">You are using the default or admin-set password. Set a new one to continue — you will be redirected here until you do.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/password" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" required>
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<label>Confirm new password
|
||||
<input type="password" name="confirm_password" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ user.username }} — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ user.username }} <span class="tag">{{ user.role }}</span></h1>
|
||||
{% if user.disabled_at %}<p class="banner">User is disabled.</p>{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Groups</h2>
|
||||
<form method="post" action="/users/{{ user.id }}/groups" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<fieldset>
|
||||
{% for g in all_groups %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="group_ids" value="{{ g.id }}"
|
||||
{% if g.id in member_of | map(attribute='id') | list %}checked{% endif %}>
|
||||
{{ g.name }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="hint">No groups exist yet — create one under Groups.</p>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<button type="submit">Save groups</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Personal access rules</h2>
|
||||
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Personal rules combine with group rules; use a personal deny to override a group allow, and vice versa.</p>
|
||||
<table>
|
||||
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rules %}
|
||||
<tr>
|
||||
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
|
||||
<td><code>{{ r.pattern }}</code></td>
|
||||
<td>
|
||||
<form method="post" action="/users/{{ user.id }}/rules/{{ r.id }}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/users/{{ user.id }}/rules" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<select name="kind">
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
<input type="text" name="pattern" placeholder="e.g. 11001.*_Value" required>
|
||||
<button type="submit">Add rule</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Set new password</h2>
|
||||
<form method="post" action="/users/{{ user.id }}/password" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<label>Password <input type="password" name="password" required minlength="8"></label>
|
||||
<button type="submit">Set password</button>
|
||||
</form>
|
||||
<p class="hint">The user must change it at next login.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users — MQTT Ingester{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Users</h1>
|
||||
<h2>Create user</h2>
|
||||
<form method="post" action="/users" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<label>Username <input type="text" name="username" required></label>
|
||||
<label>Password <input type="password" name="password" required minlength="8"></label>
|
||||
<label>Role
|
||||
<select name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Groups</legend>
|
||||
{% for g in groups %}
|
||||
<label class="check"><input type="checkbox" name="group_ids" value="{{ g.id }}"> {{ g.name }}</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<h2>Existing users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Groups</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><a href="/users/{{ u.id }}">{{ u.username }}</a></td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>{{ u.groups | join(', ') if u.groups else '—' }}</td>
|
||||
<td>
|
||||
{% if u.disabled_at %}<span class="tag disabled">disabled</span>
|
||||
{% elif u.must_change_password %}<span class="tag warn">must change pw</span>
|
||||
{% else %}<span class="tag ok">active</span>{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/users/{{ u.id }}/{% if u.disabled_at %}enable{% else %}disable{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link">{{ 'enable' if u.disabled_at else 'disable' }}</button>
|
||||
</form>
|
||||
<form method="post" action="/users/{{ u.id }}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||
<button type="submit" class="link danger">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user