from flask import Flask, render_template, request, redirect, url_for, session, flash
import traceback
import sys
import config
from auth_helpers import verify_admin
from db import get_cursor
app = Flask(__name__)
app.secret_key = config.SECRET_KEY
@app.route("/health")
def health():
"""Public endpoint to verify database connection (no login required)."""
try:
with get_cursor() as cur:
cur.execute("SELECT current_database() AS db")
row = cur.fetchone()
db_name = row["db"] if row else "?"
cur.execute("SELECT username, role, is_active FROM users WHERE role = 'admin' AND is_active = TRUE")
admins = [r["username"] for r in cur.fetchall()]
return {
"status": "ok",
"database": "connected",
"db_name": db_name,
"db_host": config.DB_AUTH_HOST,
"admin_users": admins,
"message": "Portal is connected to the database. Admin users (for login): " + ", ".join(admins) if admins else "Portal is connected. No admin users in database.",
}, 200
except Exception as e:
return {
"status": "error",
"database": "disconnected",
"error": str(e),
"db_host": config.DB_AUTH_HOST,
"db_name": config.DB_AUTH_NAME,
}, 503
@app.errorhandler(500)
def handle_500(e):
tb = traceback.format_exc()
print(tb, file=sys.stderr, flush=True)
# Show traceback in response for debugging (remove in production if desired)
escaped = tb.replace("<", "<").replace(">", ">")
return (
"
Internal Server Error
"
"The error has been logged. Details below (also in journalctl -u portal-auth-dashboard):
"
"" + escaped + "
",
500,
)
def login_required(f):
from functools import wraps
@wraps(f)
def inner(*args, **kwargs):
if not session.get("admin_username"):
flash("Please log in as an admin.", "error")
return redirect(url_for("login"))
return f(*args, **kwargs)
return inner
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
if session.get("admin_username"):
return redirect(url_for("index"))
return render_template("login.html")
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
if not username or not password:
flash("Username and password required.", "error")
return render_template("login.html")
admin_username = verify_admin(username, password)
if not admin_username:
flash("Invalid credentials or not an admin user.", "error")
return render_template("login.html")
session["admin_username"] = admin_username
session.permanent = True
return redirect(url_for("index"))
@app.route("/logout")
def logout():
session.pop("admin_username", None)
flash("Logged out.", "info")
return redirect(url_for("login"))
@app.route("/")
@login_required
def index():
return render_template("index.html")
TABLE_COLUMNS = {
"users": ["id", "username", "password_hash", "role", "created_at", "created_by", "is_active"],
"sessions": ["session_id", "username", "created_at", "expires_at"],
"auth_logs": ["id", "username", "event_type", "timestamp", "ip_address", "user_agent", "details"],
"api_tokens": ["id", "token_hash", "token_prefix", "username", "description", "created_at", "created_by", "last_used_at", "expires_at", "is_active"],
}
@app.route("/table/")
@login_required
def table_view(name):
allowed = {"users", "sessions", "auth_logs", "api_tokens"}
if name not in allowed:
flash("Unknown table.", "error")
return redirect(url_for("index"))
with get_cursor() as cur:
cur.execute(f'SELECT * FROM "{name}" ORDER BY 1 DESC LIMIT 500')
rows = cur.fetchall()
# Convert to plain dicts so Jinja and serialization don't hit RealDictRow issues
data = []
raw_rows = []
for r in rows:
data.append({k: (str(v) if v is not None else "") for k, v in r.items()})
raw_rows.append(dict(r))
columns = list(rows[0].keys()) if rows else TABLE_COLUMNS.get(name, [])
return render_template("table.html", table_name=name, rows=data, raw_rows=raw_rows, columns=columns)
@app.route("/table//edit/", methods=["GET", "POST"])
@login_required
def table_edit_user(name, pk):
if name != "users" or pk < 1:
flash("Invalid edit target.", "error")
return redirect(url_for("index"))
with get_cursor() as cur:
if request.method == "GET":
cur.execute('SELECT id, username, role, is_active FROM users WHERE id = %s', (pk,))
row = cur.fetchone()
if not row:
flash("User not found.", "error")
return redirect(url_for("table_view", name="users"))
return render_template("edit_user.html", row=row)
role = (request.form.get("role") or "support").strip()
if role not in ("admin", "support"):
role = "support"
is_active = request.form.get("is_active") == "on"
cur.execute(
"UPDATE users SET role = %s, is_active = %s WHERE id = %s",
(role, is_active, pk),
)
if cur.rowcount:
flash("User updated.", "success")
return redirect(url_for("table_view", name="users"))
@app.route("/table/users/new", methods=["GET", "POST"])
@login_required
def user_new():
import bcrypt
from datetime import datetime, timezone
if request.method == "GET":
return render_template("new_user.html")
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
role = (request.form.get("role") or "support").strip()
if role not in ("admin", "support"):
role = "support"
if not username:
flash("Username required.", "error")
return render_template("new_user.html")
if len(password) < 8:
flash("Password must be at least 8 characters.", "error")
return render_template("new_user.html", username=username, role=role)
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
now = datetime.now(timezone.utc)
admin = session.get("admin_username") or ""
with get_cursor() as cur:
try:
cur.execute(
"""INSERT INTO users (username, password_hash, role, created_at, created_by, is_active)
VALUES (%s, %s, %s, %s, %s, TRUE)""",
(username, hashed, role, now, admin if admin else None),
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
flash("Username already exists.", "error")
else:
flash("Could not create user.", "error")
return render_template("new_user.html", username=username, role=role)
flash("User created.", "success")
return redirect(url_for("table_view", name="users"))
@app.route("/table/users/password/", methods=["GET", "POST"])
@login_required
def user_password(pk):
import bcrypt
with get_cursor() as cur:
cur.execute("SELECT id, username FROM users WHERE id = %s", (pk,))
row = cur.fetchone()
if not row:
flash("User not found.", "error")
return redirect(url_for("table_view", name="users"))
if request.method == "GET":
return render_template("edit_password.html", row=row)
password = request.form.get("password") or ""
if len(password) < 8:
flash("Password must be at least 8 characters.", "error")
return render_template("edit_password.html", row=row)
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (hashed, pk))
flash("Password updated.", "success")
return redirect(url_for("table_view", name="users"))
@app.route("/table/sessions/delete", methods=["POST"])
@login_required
def session_delete():
session_id = (request.form.get("session_id") or "").strip()
if not session_id:
flash("Session ID required.", "error")
return redirect(url_for("table_view", name="sessions"))
with get_cursor() as cur:
cur.execute("DELETE FROM sessions WHERE session_id = %s", (session_id,))
flash("Session deleted.", "success")
return redirect(url_for("table_view", name="sessions"))
@app.route("/table/api_tokens/toggle/", methods=["POST"])
@login_required
def api_token_toggle(pk):
with get_cursor() as cur:
cur.execute("UPDATE api_tokens SET is_active = NOT is_active WHERE id = %s RETURNING is_active", (pk,))
row = cur.fetchone()
if row:
flash("Token " + ("activated" if row["is_active"] else "deactivated") + ".", "success")
return redirect(url_for("table_view", name="api_tokens"))
@app.route("/table/api_tokens/delete/", methods=["POST"])
@login_required
def api_token_delete(pk):
with get_cursor() as cur:
cur.execute("DELETE FROM api_tokens WHERE id = %s", (pk,))
flash("Token deleted.", "success")
return redirect(url_for("table_view", name="api_tokens"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)