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:
parent
fac5063878
commit
b09169ade2
9 changed files with 878 additions and 60 deletions
|
|
@ -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/
|
||||||
|
|
|
||||||
155
db_async.py
155
db_async.py
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
261
main.py
|
|
@ -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
268
static/admin.html
Normal 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">← 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>
|
||||||
|
|
@ -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
169
static/login.html
Normal 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>
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue