feat: add app-level authentication with login, session cookies, and admin panel

Replace Nginx basic auth with proper user accounts:
- Session cookies via itsdangerous (30-day expiry, httponly, secure)
- Password hashing with bcrypt via passlib
- Login page with AC-themed UI
- Admin page for user management (CRUD)
- AuthMiddleware exempts plugin WS and browser WS endpoints
- Issues/comments author auto-populated from session
- Sidebar shows logged-in username, admin link, and logout
- Seed users: erik (admin), alex, lundberg
- SECRET_KEY env var for cookie signing
This commit is contained in:
Erik 2026-04-10 19:45:08 +02:00
parent fac5063878
commit b09169ade2
9 changed files with 878 additions and 60 deletions

View file

@ -16,7 +16,9 @@ RUN python -m pip install --upgrade pip && \
sqlalchemy \ sqlalchemy \
alembic \ alembic \
psycopg2-binary \ psycopg2-binary \
httpx httpx \
passlib[bcrypt] \
itsdangerous
## Copy application source code and migration scripts into container ## Copy application source code and migration scripts into container
COPY static/ /app/static/ COPY static/ /app/static/

View file

@ -3,6 +3,7 @@
Defines table schemas via SQLAlchemy Core and provides an Defines table schemas via SQLAlchemy Core and provides an
initialization function to set up TimescaleDB hypertable. initialization function to set up TimescaleDB hypertable.
""" """
import os import os
import sqlalchemy import sqlalchemy
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -10,9 +11,12 @@ from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from passlib.hash import bcrypt
# Environment: Postgres/TimescaleDB connection URL # Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth") DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth"
)
# Async database client with explicit connection pool configuration and query timeout # Async database client with explicit connection pool configuration and query timeout
database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120) database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120)
# Metadata for SQLAlchemy Core # Metadata for SQLAlchemy Core
@ -48,9 +52,9 @@ telemetry_events = Table(
) )
# Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp # Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp
Index( Index(
'ix_telemetry_events_char_ts', "ix_telemetry_events_char_ts",
telemetry_events.c.character_name, telemetry_events.c.character_name,
telemetry_events.c.timestamp telemetry_events.c.timestamp,
) )
# Table for persistent total kills per character # Table for persistent total kills per character
@ -149,7 +153,12 @@ server_health_checks = Table(
Column("id", Integer, primary_key=True), Column("id", Integer, primary_key=True),
Column("server_name", String, nullable=False, index=True), Column("server_name", String, nullable=False, index=True),
Column("server_address", String, nullable=False), Column("server_address", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, default=sqlalchemy.func.now()), Column(
"timestamp",
DateTime(timezone=True),
nullable=False,
default=sqlalchemy.func.now(),
),
Column("status", String(10), nullable=False), # 'up' or 'down' Column("status", String(10), nullable=False), # 'up' or 'down'
Column("latency_ms", Float, nullable=True), Column("latency_ms", Float, nullable=True),
Column("player_count", Integer, nullable=True), Column("player_count", Integer, nullable=True),
@ -171,16 +180,18 @@ server_status = Table(
# Index for efficient server health check queries # Index for efficient server health check queries
Index( Index(
'ix_server_health_checks_name_ts', "ix_server_health_checks_name_ts",
server_health_checks.c.server_name, server_health_checks.c.server_name,
server_health_checks.c.timestamp.desc() server_health_checks.c.timestamp.desc(),
) )
character_stats = Table( character_stats = Table(
"character_stats", "character_stats",
metadata, metadata,
Column("character_name", String, primary_key=True, nullable=False), Column("character_name", String, primary_key=True, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()), Column(
"timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()
),
Column("level", Integer, nullable=True), Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True), Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True), Column("unassigned_xp", BigInteger, nullable=True),
@ -190,6 +201,20 @@ character_stats = Table(
Column("stats_data", JSON, nullable=False), Column("stats_data", JSON, nullable=False),
) )
# User accounts for app-level authentication
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("username", String, nullable=False, unique=True),
Column("password_hash", String, nullable=False),
Column("is_admin", Boolean, nullable=False, default=False),
Column(
"created_at", DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
async def init_db_async(): async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable. """Initialize PostgreSQL/TimescaleDB schema and hypertable.
@ -212,10 +237,12 @@ async def init_db_async():
print(f"Warning: failed to create extension timescaledb: {e}") print(f"Warning: failed to create extension timescaledb: {e}")
# Convert to hypertable, migrating existing data and skipping default index creation # Convert to hypertable, migrating existing data and skipping default index creation
try: try:
conn.execute(text( conn.execute(
"SELECT create_hypertable('telemetry_events', 'timestamp', " text(
"if_not_exists => true, migrate_data => true, create_default_indexes => false)" "SELECT create_hypertable('telemetry_events', 'timestamp', "
)) "if_not_exists => true, migrate_data => true, create_default_indexes => false)"
)
)
except Exception as e: except Exception as e:
print(f"Warning: failed to create hypertable telemetry_events: {e}") print(f"Warning: failed to create hypertable telemetry_events: {e}")
except Exception as e: except Exception as e:
@ -223,44 +250,56 @@ async def init_db_async():
# Ensure composite index exists for efficient time-series queries by character # Ensure composite index exists for efficient time-series queries by character
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text( conn.execute(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts " text(
"ON telemetry_events (character_name, timestamp)" "CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
)) "ON telemetry_events (character_name, timestamp)"
)
)
except Exception as e: except Exception as e:
print(f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}") print(
f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}"
)
# Add retention and compression policies on the hypertable # Add retention and compression policies on the hypertable
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Retain only recent data (default 7 days or override via DB_RETENTION_DAYS) # Retain only recent data (default 7 days or override via DB_RETENTION_DAYS)
days = int(os.getenv('DB_RETENTION_DAYS', '7')) days = int(os.getenv("DB_RETENTION_DAYS", "7"))
conn.execute(text( conn.execute(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')" text(
)) f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
)
)
# Compress chunks older than 1 day # Compress chunks older than 1 day
conn.execute(text( conn.execute(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')" text(
)) "SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
)
)
except Exception as e: except Exception as e:
print(f"Warning: failed to set retention/compression policies: {e}") print(f"Warning: failed to set retention/compression policies: {e}")
# Create unique constraint on rounded portal coordinates # Create unique constraint on rounded portal coordinates
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Drop old portal_discoveries table if it exists # Drop old portal_discoveries table if it exists
conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE")) conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE"))
# Create unique constraint on rounded coordinates for the new portals table # Create unique constraint on rounded coordinates for the new portals table
conn.execute(text( conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))""" ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))"""
)) )
)
# Create index on coordinates for efficient lookups # Create index on coordinates for efficient lookups
conn.execute(text( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)" text(
)) "CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
)
)
print("Portal table indexes and constraints created successfully") print("Portal table indexes and constraints created successfully")
except Exception as e: except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}") print(f"Warning: failed to create portal table constraints: {e}")
@ -268,7 +307,8 @@ async def init_db_async():
# Ensure character_stats table exists with JSONB column type # Ensure character_stats table exists with JSONB column type
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(text(""" conn.execute(
text("""
CREATE TABLE IF NOT EXISTS character_stats ( CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY, character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -280,25 +320,60 @@ async def init_db_async():
deaths INTEGER, deaths INTEGER,
stats_data JSONB NOT NULL stats_data JSONB NOT NULL
) )
""")) """)
)
print("character_stats table created/verified successfully") print("character_stats table created/verified successfully")
except Exception as e: except Exception as e:
print(f"Warning: failed to create character_stats table: {e}") print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals(): async def cleanup_old_portals():
"""Clean up portals older than 1 hour.""" """Clean up portals older than 1 hour."""
try: try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1) cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1)
# Delete old portals # Delete old portals
result = await database.execute( result = await database.execute(
"DELETE FROM portals WHERE discovered_at < :cutoff_time", "DELETE FROM portals WHERE discovered_at < :cutoff_time",
{"cutoff_time": cutoff_time} {"cutoff_time": cutoff_time},
) )
print(f"Cleaned up {result} portals older than 1 hour") print(f"Cleaned up {result} portals older than 1 hour")
return result return result
except Exception as e: except Exception as e:
print(f"Warning: failed to cleanup old portals: {e}") print(f"Warning: failed to cleanup old portals: {e}")
return 0 return 0
async def seed_users():
"""Seed default users if the users table is empty."""
try:
count = await database.fetch_val("SELECT COUNT(*) FROM users")
if count > 0:
print(f"Users table already has {count} users, skipping seed")
return
default_users = [
{"username": "erik", "password": "erik123", "is_admin": True},
{"username": "alex", "password": "AlexGillar100Killar", "is_admin": False},
{
"username": "lundberg",
"password": "JohanGillar100Kvinnor",
"is_admin": False,
},
]
for u in default_users:
pw_hash = bcrypt.hash(u["password"])
await database.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
{
"username": u["username"],
"password_hash": pw_hash,
"is_admin": u["is_admin"],
},
)
role = "admin" if u["is_admin"] else "user"
print(f"Seeded {role} user: {u['username']}")
except Exception as e:
print(f"Warning: failed to seed users: {e}")

View file

@ -26,6 +26,7 @@ services:
DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}" DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}"
DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}"
SHARED_SECRET: "${SHARED_SECRET}" SHARED_SECRET: "${SHARED_SECRET}"
SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "DEBUG" LOG_LEVEL: "DEBUG"
INVENTORY_SERVICE_URL: "http://inventory-service:8000" INVENTORY_SERVICE_URL: "http://inventory-service:8000"
restart: unless-stopped restart: unless-stopped

261
main.py
View file

@ -28,13 +28,22 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
Request, Request,
) )
from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.responses import (
JSONResponse,
Response,
StreamingResponse,
HTMLResponse,
RedirectResponse,
)
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from starlette.middleware.base import BaseHTTPMiddleware
import httpx import httpx
from passlib.hash import bcrypt
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
# Async database support # Async database support
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -51,8 +60,10 @@ from db_async import (
portals, portals,
server_health_checks, server_health_checks,
server_status, server_status,
users,
init_db_async, init_db_async,
cleanup_old_portals, cleanup_old_portals,
seed_users,
) )
import asyncio import asyncio
@ -987,11 +998,72 @@ dungeon_map_cache: Dict[str, dict] = {} # landblock hex string -> dungeon map d
# Shared secret used to authenticate plugin WebSocket connections (override for production) # Shared secret used to authenticate plugin WebSocket connections (override for production)
SHARED_SECRET = "your_shared_secret" SHARED_SECRET = "your_shared_secret"
# Secret key for signing session cookies (override via SECRET_KEY env var)
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please")
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days in seconds
_serializer = URLSafeTimedSerializer(SECRET_KEY)
# LOG_FILE = "telemetry_log.jsonl" # LOG_FILE = "telemetry_log.jsonl"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
ACTIVE_WINDOW = timedelta( ACTIVE_WINDOW = timedelta(
seconds=30 seconds=30
) # Time window defining “online” players (last 30 seconds) ) # Time window defining "online" players (last 30 seconds)
# ─── Session helpers ─────────────────────────────────────────────
def create_session_cookie(username: str, is_admin: bool) -> str:
"""Create a signed session token."""
return _serializer.dumps({"u": username, "a": is_admin})
def verify_session_cookie(token: str) -> dict | None:
"""Verify and decode a session token. Returns None if invalid/expired."""
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE)
return {"username": data["u"], "is_admin": data["a"]}
except (BadSignature, SignatureExpired, KeyError):
return None
# Paths that don't require authentication
_PUBLIC_PATHS = {"/login", "/logout"}
_PUBLIC_PREFIXES = ("/ws/position",) # Plugin WS uses X-Plugin-Secret
class AuthMiddleware(BaseHTTPMiddleware):
"""Redirect unauthenticated requests to /login."""
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Always allow public paths
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES):
return await call_next(request)
# Allow login page static assets
if path == "/login.html" or path == "/login-style.css":
return await call_next(request)
# WebSocket upgrade for /ws/live — allow (browser WS, read-only)
if path.startswith("/ws/live"):
return await call_next(request)
# Check session cookie
token = request.cookies.get("session")
if token:
user = verify_session_cookie(token)
if user:
request.state.user = user
return await call_next(request)
# Not authenticated — redirect browser, reject API
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse("/login", status_code=302)
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
app.add_middleware(AuthMiddleware)
""" """
Data models for plugin events: Data models for plugin events:
@ -1159,6 +1231,8 @@ async def on_startup():
logger.info( logger.info(
"Background cache refresh, server monitoring, and connection cleanup tasks started" "Background cache refresh, server monitoring, and connection cleanup tasks started"
) )
# Seed default users on first run
await seed_users()
@app.on_event("shutdown") @app.on_event("shutdown")
@ -1212,6 +1286,175 @@ async def on_shutdown():
await database.disconnect() await database.disconnect()
# ─── Authentication endpoints ────────────────────────────────────
@app.get("/login")
async def login_page():
"""Serve the login page."""
login_html = Path("static/login.html")
if login_html.exists():
return HTMLResponse(login_html.read_text())
return HTMLResponse("<h1>Login page not found</h1>", status_code=500)
@app.post("/login")
async def login(request: Request):
"""Authenticate user and set session cookie."""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid request body")
username = body.get("username", "").strip().lower()
password = body.get("password", "")
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
row = await database.fetch_one(
"SELECT id, username, password_hash, is_admin FROM users WHERE LOWER(username) = :username",
{"username": username},
)
if not row or not bcrypt.verify(password, row["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_session_cookie(row["username"], row["is_admin"])
response = JSONResponse(
{"ok": True, "username": row["username"], "is_admin": row["is_admin"]}
)
response.set_cookie(
"session",
token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=True,
)
return response
@app.get("/logout")
async def logout():
"""Clear session cookie and redirect to login."""
response = RedirectResponse("/login", status_code=302)
response.delete_cookie("session")
return response
@app.get("/me")
async def me(request: Request):
"""Return current user info from session."""
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return {"username": user["username"], "is_admin": user["is_admin"]}
# ─── Admin user management ───────────────────────────────────────
def _require_admin(request: Request):
"""Raise 403 if current user is not admin."""
user = getattr(request.state, "user", None)
if not user or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
@app.get("/admin/users")
async def admin_page(request: Request):
"""Serve the admin user management page."""
_require_admin(request)
admin_html = Path("static/admin.html")
if admin_html.exists():
return HTMLResponse(admin_html.read_text())
return HTMLResponse("<h1>Admin page not found</h1>", status_code=500)
@app.get("/api-admin/users")
async def list_users(request: Request):
"""List all users (admin only)."""
_require_admin(request)
rows = await database.fetch_all(
"SELECT id, username, is_admin, created_at FROM users ORDER BY id"
)
return {"users": [dict(r) for r in rows]}
@app.post("/api-admin/users")
async def create_user(request: Request):
"""Create a new user (admin only)."""
_require_admin(request)
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "")
is_admin = bool(body.get("is_admin", False))
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
if len(password) < 4:
raise HTTPException(
status_code=400, detail="Password must be at least 4 characters"
)
existing = await database.fetch_one(
"SELECT id FROM users WHERE LOWER(username) = :username",
{"username": username.lower()},
)
if existing:
raise HTTPException(status_code=409, detail="Username already exists")
pw_hash = bcrypt.hash(password)
await database.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
{"username": username, "password_hash": pw_hash, "is_admin": is_admin},
)
return {"ok": True, "username": username}
@app.delete("/api-admin/users/{user_id}")
async def delete_user(user_id: int, request: Request):
"""Delete a user (admin only). Cannot delete yourself."""
_require_admin(request)
current_user = request.state.user["username"]
row = await database.fetch_one(
"SELECT username FROM users WHERE id = :id", {"id": user_id}
)
if not row:
raise HTTPException(status_code=404, detail="User not found")
if row["username"].lower() == current_user.lower():
raise HTTPException(status_code=400, detail="Cannot delete yourself")
await database.execute("DELETE FROM users WHERE id = :id", {"id": user_id})
return {"ok": True}
@app.patch("/api-admin/users/{user_id}")
async def update_user(user_id: int, request: Request):
"""Update user (admin only). Supports password reset and admin toggle."""
_require_admin(request)
body = await request.json()
row = await database.fetch_one(
"SELECT id, username FROM users WHERE id = :id", {"id": user_id}
)
if not row:
raise HTTPException(status_code=404, detail="User not found")
if "password" in body:
password = body["password"]
if len(password) < 4:
raise HTTPException(
status_code=400, detail="Password must be at least 4 characters"
)
pw_hash = bcrypt.hash(password)
await database.execute(
"UPDATE users SET password_hash = :pw WHERE id = :id",
{"pw": pw_hash, "id": user_id},
)
if "is_admin" in body:
await database.execute(
"UPDATE users SET is_admin = :admin WHERE id = :id",
{"admin": bool(body["is_admin"]), "id": user_id},
)
return {"ok": True}
# ------------------------ GET ----------------------------------- # ------------------------ GET -----------------------------------
@app.get("/debug") @app.get("/debug")
def debug(): def debug():
@ -1358,15 +1601,16 @@ async def get_issues():
@app.post("/issues") @app.post("/issues")
async def add_issue(issue: dict): async def add_issue(request: Request, issue: dict):
"""Add a new issue.""" """Add a new issue. Author from session."""
user = getattr(request.state, "user", {})
issues = _load_issues() issues = _load_issues()
new_issue = { new_issue = {
"id": uuid.uuid4().hex[:8], "id": uuid.uuid4().hex[:8],
"title": issue.get("title", "").strip(), "title": issue.get("title", "").strip(),
"description": issue.get("description", "").strip(), "description": issue.get("description", "").strip(),
"category": issue.get("category", "other"), "category": issue.get("category", "other"),
"author": issue.get("author", "Anonymous").strip(), "author": user.get("username", "Anonymous"),
"created": datetime.utcnow().isoformat(), "created": datetime.utcnow().isoformat(),
"resolved": False, "resolved": False,
"comments": [], "comments": [],
@ -1405,8 +1649,9 @@ async def update_issue(issue_id: str, update: dict):
@app.post("/issues/{issue_id}/comments") @app.post("/issues/{issue_id}/comments")
async def add_comment(issue_id: str, comment: dict): async def add_comment(issue_id: str, request: Request, comment: dict):
"""Add a comment to an issue.""" """Add a comment to an issue. Author from session."""
user = getattr(request.state, "user", {})
issues = _load_issues() issues = _load_issues()
found = None found = None
for i in issues: for i in issues:
@ -1420,7 +1665,7 @@ async def add_comment(issue_id: str, comment: dict):
raise HTTPException(status_code=400, detail="Comment text is required") raise HTTPException(status_code=400, detail="Comment text is required")
new_comment = { new_comment = {
"id": uuid.uuid4().hex[:8], "id": uuid.uuid4().hex[:8],
"author": comment.get("author", "Anonymous").strip(), "author": user.get("username", "Anonymous"),
"text": text, "text": text,
"created": datetime.utcnow().isoformat(), "created": datetime.utcnow().isoformat(),
} }

268
static/admin.html Normal file
View file

@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
background: #0a0a0a;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
color: #d4c9a8;
padding: 40px;
}
.admin-container {
max-width: 600px;
margin: 0 auto;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 2px solid #8a7a44;
border-radius: 6px;
padding: 24px;
box-shadow: 0 8px 32px rgba(0,0,0,0.8);
}
h1 {
color: #d4af37;
font-size: 1.3rem;
margin-bottom: 20px;
text-align: center;
letter-spacing: 1px;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
color: #8a7a44;
text-decoration: none;
font-size: 0.8rem;
}
.back-link:hover { color: #d4af37; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #2a2418;
font-size: 0.8rem;
}
th {
color: #a09070;
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 1px;
}
td { color: #d4c9a8; }
.badge-admin {
display: inline-block;
padding: 1px 6px;
background: #d4af37;
color: #1a1610;
border-radius: 3px;
font-size: 0.65rem;
font-weight: bold;
}
.badge-user {
display: inline-block;
padding: 1px 6px;
background: #3a3a3a;
color: #aaa;
border-radius: 3px;
font-size: 0.65rem;
}
.btn {
padding: 3px 8px;
font-size: 0.7rem;
font-family: inherit;
cursor: pointer;
border-radius: 3px;
border: 1px solid;
background: transparent;
margin-right: 4px;
}
.btn-danger { color: #c44; border-color: #c44; }
.btn-danger:hover { background: #c44; color: #fff; }
.btn-warn { color: #ca4; border-color: #ca4; }
.btn-warn:hover { background: #ca4; color: #fff; }
.add-form {
padding: 16px;
background: #151210;
border: 1px solid #3a2818;
border-radius: 4px;
}
.add-form h3 {
color: #a09070;
font-size: 0.85rem;
margin-bottom: 12px;
}
.form-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.form-row input {
flex: 1;
padding: 6px 10px;
font-size: 0.85rem;
font-family: inherit;
background: #0e0c08;
color: #d4c9a8;
border: 1px solid #5a4a24;
border-radius: 3px;
outline: none;
}
.form-row input:focus { border-color: #d4af37; }
.form-row label {
font-size: 0.75rem;
color: #a09070;
white-space: nowrap;
}
.form-row input[type="checkbox"] {
flex: none;
width: 16px;
height: 16px;
}
.btn-add {
padding: 6px 16px;
font-size: 0.85rem;
font-family: inherit;
font-weight: bold;
color: #1a1610;
background: linear-gradient(180deg, #d4af37 0%, #a08520 100%);
border: 1px solid #8a7a44;
border-radius: 3px;
cursor: pointer;
}
.btn-add:hover { background: linear-gradient(180deg, #e0c050 0%, #b89a30 100%); }
.msg {
margin-top: 8px;
font-size: 0.75rem;
padding: 6px;
border-radius: 3px;
display: none;
}
.msg-error { color: #ff6b6b; background: rgba(255,50,50,0.08); border: 1px solid rgba(255,50,50,0.2); }
.msg-ok { color: #4a4; background: rgba(74,170,74,0.08); border: 1px solid rgba(74,170,74,0.2); }
</style>
</head>
<body>
<div class="admin-container">
<a href="/" class="back-link">&larr; Back to Tracker</a>
<h1>User Management</h1>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
<tbody id="userTableBody"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
<div class="add-form">
<h3>Add New User</h3>
<div class="form-row">
<input type="text" id="newUsername" placeholder="Username">
<input type="password" id="newPassword" placeholder="Password">
<label><input type="checkbox" id="newIsAdmin"> Admin</label>
<button class="btn-add" onclick="addUser()">Add</button>
</div>
<div class="msg msg-error" id="addError"></div>
<div class="msg msg-ok" id="addOk"></div>
</div>
</div>
<script>
async function loadUsers() {
const tbody = document.getElementById('userTableBody');
try {
const resp = await fetch('/api-admin/users');
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
tbody.innerHTML = '';
data.users.forEach(u => {
const date = u.created_at ? new Date(u.created_at).toLocaleDateString('sv-SE') : '';
const role = u.is_admin
? '<span class="badge-admin">ADMIN</span>'
: '<span class="badge-user">USER</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(u.username)}</td>
<td>${role}</td>
<td>${date}</td>
<td>
<button class="btn btn-warn" onclick="resetPw(${u.id}, '${esc(u.username)}')">Reset PW</button>
<button class="btn btn-danger" onclick="delUser(${u.id}, '${esc(u.username)}')">Delete</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4" style="color:#c44">Failed to load users</td></tr>';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function addUser() {
const errDiv = document.getElementById('addError');
const okDiv = document.getElementById('addOk');
errDiv.style.display = 'none';
okDiv.style.display = 'none';
const username = document.getElementById('newUsername').value.trim();
const password = document.getElementById('newPassword').value;
const is_admin = document.getElementById('newIsAdmin').checked;
if (!username || !password) { showMsg(errDiv, 'Username and password required'); return; }
try {
const resp = await fetch('/api-admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, is_admin }),
});
if (!resp.ok) {
const d = await resp.json();
showMsg(errDiv, d.detail || 'Failed');
return;
}
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('newIsAdmin').checked = false;
showMsg(okDiv, `User "${username}" created`);
loadUsers();
} catch (e) { showMsg(errDiv, 'Connection error'); }
}
async function delUser(id, name) {
if (!confirm(`Delete user "${name}"?`)) return;
try {
const resp = await fetch(`/api-admin/users/${id}`, { method: 'DELETE' });
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
loadUsers();
} catch (e) { alert('Connection error'); }
}
async function resetPw(id, name) {
const pw = prompt(`New password for "${name}":`);
if (!pw) return;
try {
const resp = await fetch(`/api-admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
alert('Password updated');
} catch (e) { alert('Connection error'); }
}
function showMsg(el, text) {
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
loadUsers();
</script>
</body>
</html>

View file

@ -113,6 +113,13 @@
<ul id="playerList"></ul> <ul id="playerList"></ul>
<!-- User info section (populated by script.js after /me fetch) -->
<div id="userInfo" class="user-info" style="display:none;">
<span id="currentUsername" class="user-info-name"></span>
<a href="#" id="adminLink" class="user-info-admin" style="display:none;" onclick="window.open('/admin/users','_blank')">Admin</a>
<a href="/logout" class="user-info-logout">Logout</a>
</div>
</aside> </aside>
<!-- Epic rare notifications container --> <!-- Epic rare notifications container -->

169
static/login.html Normal file
View file

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker - Login</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
background-image:
radial-gradient(ellipse at 50% 30%, rgba(30, 20, 10, 0.8) 0%, transparent 70%),
linear-gradient(180deg, #0a0806 0%, #12100a 50%, #0a0806 100%);
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
color: #d4c9a8;
}
.login-card {
width: 360px;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 2px solid #8a7a44;
border-radius: 6px;
padding: 32px 28px;
box-shadow:
inset 0 1px 0 rgba(212, 175, 55, 0.1),
0 8px 32px rgba(0, 0, 0, 0.8),
0 0 60px rgba(138, 122, 68, 0.08);
}
.login-title {
text-align: center;
margin-bottom: 6px;
font-size: 1.5rem;
color: #d4af37;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
letter-spacing: 1px;
}
.login-subtitle {
text-align: center;
font-size: 0.8rem;
color: #8a7a5a;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 0.8rem;
color: #a09070;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group input {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
font-family: inherit;
background: #0e0c08;
color: #d4c9a8;
border: 1px solid #5a4a24;
border-radius: 3px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #d4af37;
box-shadow: 0 0 6px rgba(212, 175, 55, 0.15);
}
.login-btn {
width: 100%;
padding: 10px;
margin-top: 8px;
font-family: inherit;
font-size: 1rem;
font-weight: bold;
color: #1a1610;
background: linear-gradient(180deg, #d4af37 0%, #a08520 100%);
border: 1px solid #8a7a44;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 2px;
transition: background 0.2s, box-shadow 0.2s;
}
.login-btn:hover {
background: linear-gradient(180deg, #e0c050 0%, #b89a30 100%);
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.3);
}
.login-btn:active {
background: linear-gradient(180deg, #a08520 0%, #8a7a44 100%);
}
.login-error {
margin-top: 12px;
padding: 8px;
text-align: center;
font-size: 0.8rem;
color: #ff6b6b;
background: rgba(255, 50, 50, 0.08);
border: 1px solid rgba(255, 50, 50, 0.2);
border-radius: 3px;
display: none;
}
.login-footer {
margin-top: 20px;
text-align: center;
font-size: 0.65rem;
color: #5a4a34;
}
</style>
</head>
<body>
<div class="login-card">
<h1 class="login-title">Dereth Tracker</h1>
<p class="login-subtitle">Mosswart Enjoyers Club</p>
<form id="loginForm" onsubmit="return handleLogin(event)">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">Enter Dereth</button>
<div class="login-error" id="loginError"></div>
</form>
<div class="login-footer">Authorized personnel only</div>
</div>
<script>
async function handleLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const errDiv = document.getElementById('loginError');
errDiv.style.display = 'none';
btn.textContent = 'Authenticating...';
btn.disabled = true;
try {
const resp = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
window.location.href = '/';
return;
}
const data = await resp.json();
errDiv.textContent = data.detail || 'Login failed';
errDiv.style.display = 'block';
} catch (err) {
errDiv.textContent = 'Connection error';
errDiv.style.display = 'block';
}
btn.textContent = 'Enter Dereth';
btn.disabled = false;
}
</script>
</body>
</html>

View file

@ -4125,6 +4125,26 @@ fetch('/api-version').then(r => r.json()).then(d => {
if (el) el.textContent = 'v' + d.version; if (el) el.textContent = 'v' + d.version;
}).catch(() => {}); }).catch(() => {});
// ─── Current User Info ──────────────────────────────────────────
let _currentUser = null;
fetch('/me').then(r => {
if (!r.ok) throw new Error('not authenticated');
return r.json();
}).then(data => {
_currentUser = data;
const userInfo = document.getElementById('userInfo');
const nameEl = document.getElementById('currentUsername');
const adminLink = document.getElementById('adminLink');
if (userInfo && nameEl) {
nameEl.textContent = data.username;
userInfo.style.display = 'flex';
}
if (adminLink && data.is_admin) {
adminLink.style.display = 'inline';
}
}).catch(() => {});
// ─── Issues Board ─────────────────────────────────────────────── // ─── Issues Board ───────────────────────────────────────────────
const ISSUE_CATEGORIES = { const ISSUE_CATEGORIES = {
@ -4165,7 +4185,6 @@ function showIssuesWindow() {
form.className = 'issues-form'; form.className = 'issues-form';
form.innerHTML = ` form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;"> <div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" id="issueAuthor" placeholder="Your name..." style="width:120px;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<input type="text" id="issueTitle" placeholder="Issue title..." style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;"> <input type="text" id="issueTitle" placeholder="Issue title..." style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;"> <select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin">Plugin</option> <option value="plugin">Plugin</option>
@ -4182,24 +4201,17 @@ function showIssuesWindow() {
`; `;
content.appendChild(form); content.appendChild(form);
// Remember author name in localStorage
const authorInput = form.querySelector('#issueAuthor');
authorInput.value = localStorage.getItem('issueAuthorName') || '';
// Add button handler // Add button handler
form.querySelector('#issueAddBtn').addEventListener('click', async () => { form.querySelector('#issueAddBtn').addEventListener('click', async () => {
const author = document.getElementById('issueAuthor').value.trim() || 'Anonymous';
const title = document.getElementById('issueTitle').value.trim(); const title = document.getElementById('issueTitle').value.trim();
const desc = document.getElementById('issueDescription').value.trim(); const desc = document.getElementById('issueDescription').value.trim();
const cat = document.getElementById('issueCategory').value; const cat = document.getElementById('issueCategory').value;
if (!title) return; if (!title) return;
localStorage.setItem('issueAuthorName', author);
await fetch('/issues', { await fetch('/issues', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc, category: cat, author }) body: JSON.stringify({ title, description: desc, category: cat })
}); });
document.getElementById('issueTitle').value = ''; document.getElementById('issueTitle').value = '';
document.getElementById('issueDescription').value = ''; document.getElementById('issueDescription').value = '';
@ -4406,11 +4418,10 @@ function showCommentsSection(row, issue, win) {
addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => { addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => {
const text = addDiv.querySelector('.comment-text-input').value.trim(); const text = addDiv.querySelector('.comment-text-input').value.trim();
if (!text) return; if (!text) return;
const author = localStorage.getItem('issueAuthorName') || 'Anonymous';
await fetch(`/issues/${issue.id}/comments`, { await fetch(`/issues/${issue.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, author }) body: JSON.stringify({ text })
}); });
refreshIssuesList(win); refreshIssuesList(win);
}); });

View file

@ -117,6 +117,8 @@ body {
box-sizing: border-box; box-sizing: border-box;
padding: 18px 16px; padding: 18px 16px;
overflow-y: auto; overflow-y: auto;
display: flex;
flex-direction: column;
} }
#sidebar h2 { #sidebar h2 {
margin: 8px 0 12px; margin: 8px 0 12px;
@ -2832,3 +2834,41 @@ table.ts-allegiance td:first-child {
.issue-comment-form { .issue-comment-form {
margin-top: 4px; margin-top: 4px;
} }
/* ---------- User info section (sidebar bottom) ---------- */
.user-info {
margin-top: auto;
padding: 10px 0 0;
border-top: 1px solid #333;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
flex-shrink: 0;
}
.user-info-name {
color: #d4af37;
font-weight: bold;
}
.user-info-admin {
color: #8a7a44;
text-decoration: none;
font-size: 0.7rem;
}
.user-info-admin:hover {
color: #d4af37;
}
.user-info-logout {
margin-left: auto;
color: #888;
text-decoration: none;
font-size: 0.7rem;
}
.user-info-logout:hover {
color: #ff6b6b;
}