admin improvements

This commit is contained in:
SowinskiBraeden committed 2026-06-24 23:15:13 -07:00
1 parent f078034789
commit 5c7a498af6
18 files changed
+2261 -4

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ from .extensions import csrf, limiter, mail
from .routes.account import account_bp
from .routes.leagues import leagues_bp
from .routes.public import public_bp
from .routes.site_admin import site_admin_bp
from .storage import ensure_data_file
from .utils import cents_to_dollars, safe_date_label
@@ -44,6 +45,7 @@ def create_app(config_overrides: dict | None = None) -> Flask:
app.register_blueprint(public_bp)
app.register_blueprint(account_bp)
app.register_blueprint(leagues_bp)
app.register_blueprint(site_admin_bp)
@app.cli.command("init-db")
def init_db_command() -> None:
+25
View File
@@ -77,3 +77,28 @@ def login_required(view):
return view(*args, **kwargs)
return wrapped_view
def current_user_is_site_admin() -> bool:
user_id = current_user_id()
if not user_id:
return False
from .db import db
from .db_models import User
if db is None:
return False
user = db.session.get(User, user_id)
return bool(user and user.is_site_admin and user.disabled_at is None)
def site_admin_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
if not is_logged_in():
return redirect(url_for("account.login", next=request.full_path))
if not current_user_is_site_admin():
from flask import abort
abort(403)
return view(*args, **kwargs)
return wrapped_view
+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.3.1"
APP_VERSION = "2.4.3"
def load_local_env(env_path: Path) -> None:
+1
View File
@@ -75,6 +75,7 @@ class User(TimestampMixin, db.Model):
email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True)
last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
is_site_admin = db.Column(db.Boolean, nullable=False, default=False)
class League(TimestampMixin, db.Model):
+20
View File
@@ -322,3 +322,23 @@ def remove_league_member(league_id: str, user_id: str) -> None:
).one_or_none()
if membership and membership.role != "owner":
membership.disabled_at = utc_now()
def transfer_league_ownership(league_id: str, new_owner_user_id: str) -> None:
current_owner = LeagueMembership.query.filter_by(
league_id=league_id,
role="owner",
disabled_at=None,
).one_or_none()
new_owner_membership = LeagueMembership.query.filter_by(
league_id=league_id,
user_id=new_owner_user_id,
disabled_at=None,
).one_or_none()
if current_owner is None or new_owner_membership is None:
raise ValueError("Invalid transfer: owner or target not found.")
league = db.session.get(League, league_id)
if league:
league.created_by_user_id = new_owner_user_id
current_owner.role = "manager"
new_owner_membership.role = "owner"
+33 -1
View File
@@ -1127,7 +1127,8 @@ def league_settings(league_ref: str):
from ..repositories.leagues import list_members_for_league
members = list_members_for_league(league.id)
return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members)
has_managers = any(m.role == "manager" for m, u in members)
return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members, has_managers=has_managers)
@leagues_bp.post("/l/<league_ref>/settings/invite")
@@ -1196,6 +1197,37 @@ def remove_member(league_ref: str, user_id: str):
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
@leagues_bp.post("/l/<league_ref>/settings/transfer")
@login_required
def transfer_ownership(league_ref: str):
if not db_ready():
flash("League database is not available.", "error")
return redirect(url_for("public.home"))
from ..repositories.leagues import find_membership, transfer_league_ownership
league = require_league(league_ref, {"owner"})
new_owner_id = request.form.get("new_owner_user_id", "").strip()
if not new_owner_id:
flash("Select a manager to transfer ownership to.", "error")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
membership = find_membership(league.id, new_owner_id)
if membership is None or membership.role != "manager":
flash("Target user must be a manager of this league.", "error")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
try:
transfer_league_ownership(league.id, new_owner_id)
db.session.commit()
flash("Ownership transferred. You are now a manager of this league.", "success")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("leagues.dashboard", **league_url_values(league)))
@leagues_bp.post("/l/<league_ref>/archive")
@login_required
def archive_league(league_ref: str):
+614
View File
@@ -0,0 +1,614 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from flask import Blueprint, flash, redirect, render_template, request, url_for
from ..auth import (
current_user_id,
generate_reset_token,
hash_password,
normalize_email,
site_admin_required,
)
from ..db import db
site_admin_bp = Blueprint("site_admin", __name__, url_prefix="/admin")
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@site_admin_bp.get("/")
def index():
return redirect(url_for("site_admin.dashboard"))
@site_admin_bp.get("/dashboard")
@site_admin_required
def dashboard():
from ..db_models import League, LedgerEvent, LeagueMembership, PokerSession, User
now = utc_now()
day_ago = now - timedelta(days=1)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
total_users = db.session.query(db.func.count(User.id)).scalar() or 0
active_users = db.session.query(db.func.count(User.id)).filter(User.disabled_at.is_(None)).scalar() or 0
disabled_users = total_users - active_users
new_users_7d = db.session.query(db.func.count(User.id)).filter(User.created_at >= week_ago).scalar() or 0
new_users_30d = db.session.query(db.func.count(User.id)).filter(User.created_at >= month_ago).scalar() or 0
admin_count = db.session.query(db.func.count(User.id)).filter(User.is_site_admin.is_(True)).scalar() or 0
dau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= day_ago).scalar() or 0
wau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= week_ago).scalar() or 0
mau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= month_ago).scalar() or 0
total_leagues = db.session.query(db.func.count(League.id)).scalar() or 0
active_leagues = db.session.query(db.func.count(League.id)).filter(League.archived_at.is_(None)).scalar() or 0
public_leagues = db.session.query(db.func.count(League.id)).filter(
League.visibility == "public", League.archived_at.is_(None)
).scalar() or 0
new_leagues_30d = db.session.query(db.func.count(League.id)).filter(League.created_at >= month_ago).scalar() or 0
total_sessions = db.session.query(db.func.count(PokerSession.id)).scalar() or 0
open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(PokerSession.status == "open").scalar() or 0
total_events = db.session.query(db.func.count(LedgerEvent.id)).filter(LedgerEvent.voided_at.is_(None)).scalar() or 0
league_session_rows = db.session.query(
PokerSession.league_id, db.func.count(PokerSession.id)
).group_by(PokerSession.league_id).all()
avg_sessions_per_league = round(
sum(r[1] for r in league_session_rows) / len(league_session_rows), 1
) if league_session_rows else 0
league_buyin_rows = db.session.query(
LedgerEvent.league_id, db.func.sum(LedgerEvent.amount_cents)
).filter(
LedgerEvent.event_type == "buyin",
LedgerEvent.voided_at.is_(None),
).group_by(LedgerEvent.league_id).all()
avg_buyin_cents = int(
sum(r[1] for r in league_buyin_rows) / len(league_buyin_rows)
) if league_buyin_rows else 0
today = now.date()
chart_days = [today - timedelta(days=i) for i in range(29, -1, -1)]
chart_labels = [d.strftime("%b %d") for d in chart_days]
chart_iso = [d.isoformat() for d in chart_days]
signup_raw = db.session.query(
db.func.date(User.created_at),
db.func.count(User.id),
).filter(User.created_at >= month_ago).group_by(db.func.date(User.created_at)).all()
signup_by_day = {
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
for r in signup_raw
}
chart_signups = [signup_by_day.get(d, 0) for d in chart_iso]
login_raw = db.session.query(
db.func.date(User.last_login_at),
db.func.count(User.id),
).filter(User.last_login_at >= month_ago).group_by(db.func.date(User.last_login_at)).all()
login_by_day = {
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
for r in login_raw
}
chart_logins = [login_by_day.get(d, 0) for d in chart_iso]
session_raw = db.session.query(
PokerSession.session_date,
db.func.count(PokerSession.id),
).filter(PokerSession.session_date >= chart_days[0]).group_by(PokerSession.session_date).all()
session_by_day = {
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
for r in session_raw
}
chart_sessions = [session_by_day.get(d, 0) for d in chart_iso]
chart_data = json.dumps({
"labels": chart_labels,
"signups": chart_signups,
"logins": chart_logins,
"sessions": chart_sessions,
})
recent_users = (
db.session.query(User)
.order_by(User.created_at.desc())
.limit(10)
.all()
)
recent_leagues = (
db.session.query(League)
.order_by(League.created_at.desc())
.limit(10)
.all()
)
league_owner_ids = {league.created_by_user_id for league in recent_leagues}
owners_by_id = {
u.id: u
for u in db.session.query(User).filter(User.id.in_(league_owner_ids)).all()
} if league_owner_ids else {}
stats = {
"total_users": total_users,
"active_users": active_users,
"disabled_users": disabled_users,
"new_users_7d": new_users_7d,
"new_users_30d": new_users_30d,
"admin_count": admin_count,
"dau": dau,
"wau": wau,
"mau": mau,
"total_leagues": total_leagues,
"active_leagues": active_leagues,
"public_leagues": public_leagues,
"new_leagues_30d": new_leagues_30d,
"total_sessions": total_sessions,
"open_sessions": open_sessions,
"total_events": total_events,
"avg_sessions_per_league": avg_sessions_per_league,
"avg_buyin_cents": avg_buyin_cents,
}
return render_template(
"admin/dashboard.html",
stats=stats,
recent_users=recent_users,
recent_leagues=recent_leagues,
owners_by_id=owners_by_id,
chart_data=chart_data,
)
@site_admin_bp.get("/users")
@site_admin_required
def users():
from ..db_models import League, LeagueMembership, User
search = request.args.get("q", "").strip()
page = max(1, int(request.args.get("page", 1)))
per_page = 50
query = db.session.query(User)
if search:
query = query.filter(User.email.ilike(f"%{search}%"))
query = query.order_by(User.created_at.desc())
total = query.count()
user_list = query.offset((page - 1) * per_page).limit(per_page).all()
user_ids = [u.id for u in user_list]
league_counts = {}
if user_ids:
rows = (
db.session.query(
LeagueMembership.user_id,
db.func.count(LeagueMembership.id),
)
.filter(LeagueMembership.user_id.in_(user_ids))
.group_by(LeagueMembership.user_id)
.all()
)
league_counts = {row[0]: row[1] for row in rows}
total_pages = max(1, (total + per_page - 1) // per_page)
return render_template(
"admin/users.html",
users=user_list,
league_counts=league_counts,
search=search,
page=page,
total=total,
total_pages=total_pages,
)
@site_admin_bp.get("/users/<user_id>")
@site_admin_required
def user_detail(user_id: str):
from flask import current_app
from ..db_models import League, LeagueMembership, User
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
memberships = (
db.session.query(LeagueMembership, League)
.join(League, League.id == LeagueMembership.league_id)
.filter(LeagueMembership.user_id == user_id)
.order_by(LeagueMembership.created_at.desc())
.all()
)
reset_url = None
if request.args.get("show_reset") == "1":
token = generate_reset_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
return render_template(
"admin/user_detail.html",
user=user,
memberships=memberships,
reset_url=reset_url,
viewing_self=user_id == current_user_id(),
)
@site_admin_bp.post("/users/<user_id>/send-reset")
@site_admin_required
def send_reset(user_id: str):
from flask import current_app
from ..db_models import User
from ..emails import send_password_reset
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
if user.disabled_at is not None:
flash("Cannot send a reset link to a disabled account.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
token = generate_reset_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
email_sent = False
try:
send_password_reset(user.email, reset_url)
email_sent = True
except Exception:
pass
if email_sent:
flash(f"Password reset email sent to {user.email}.", "success")
else:
flash("Email not sent (mail not configured). Copy the link below.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1"))
@site_admin_bp.post("/users/<user_id>/disable")
@site_admin_required
def disable_user(user_id: str):
from ..db_models import User
if user_id == current_user_id():
flash("You cannot disable your own account.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
if user.disabled_at is None:
user.disabled_at = utc_now()
db.session.commit()
flash(f"{user.email} has been disabled.", "success")
else:
flash("Account is already disabled.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.post("/users/<user_id>/enable")
@site_admin_required
def enable_user(user_id: str):
from ..db_models import User
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
if user.disabled_at is not None:
user.disabled_at = None
db.session.commit()
flash(f"{user.email} has been re-enabled.", "success")
else:
flash("Account is not disabled.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.post("/users/<user_id>/grant-admin")
@site_admin_required
def grant_admin(user_id: str):
from ..db_models import User
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
user.is_site_admin = True
db.session.commit()
flash(f"{user.email} is now a site admin.", "success")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.post("/users/<user_id>/revoke-admin")
@site_admin_required
def revoke_admin(user_id: str):
from ..db_models import User
if user_id == current_user_id():
flash("You cannot revoke your own admin access.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
user.is_site_admin = False
db.session.commit()
flash(f"Admin access removed from {user.email}.", "success")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.post("/users/<user_id>/reset-password")
@site_admin_required
def admin_reset_password(user_id: str):
from ..db_models import User
user = db.session.get(User, user_id)
if user is None:
flash("User not found.", "error")
return redirect(url_for("site_admin.users"))
new_password = request.form.get("new_password", "")
if len(new_password) < 8:
flash("Password must be at least 8 characters.", "error")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
user.password_hash = hash_password(new_password)
db.session.commit()
flash(f"Password updated for {user.email}.", "success")
return redirect(url_for("site_admin.user_detail", user_id=user_id))
@site_admin_bp.get("/leagues")
@site_admin_required
def leagues():
from ..db_models import League, LeagueMembership, PokerSession, User
search = request.args.get("q", "").strip()
visibility = request.args.get("visibility", "").strip()
show_archived = request.args.get("archived", "0") == "1"
page = max(1, int(request.args.get("page", 1)))
per_page = 50
query = (
db.session.query(League)
.outerjoin(User, User.id == League.created_by_user_id)
)
if search:
query = query.filter(
db.or_(
League.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
)
if visibility in ("public", "private"):
query = query.filter(League.visibility == visibility)
if not show_archived:
query = query.filter(League.archived_at.is_(None))
query = query.order_by(League.created_at.desc())
total = query.count()
league_list = query.offset((page - 1) * per_page).limit(per_page).all()
league_ids = [lg.id for lg in league_list]
owner_ids = [lg.created_by_user_id for lg in league_list]
owners_by_id = {}
if owner_ids:
owners_by_id = {
u.id: u
for u in db.session.query(User).filter(User.id.in_(owner_ids)).all()
}
member_counts = {}
session_counts = {}
if league_ids:
for row in (
db.session.query(LeagueMembership.league_id, db.func.count(LeagueMembership.id))
.filter(LeagueMembership.league_id.in_(league_ids))
.group_by(LeagueMembership.league_id)
.all()
):
member_counts[row[0]] = row[1]
for row in (
db.session.query(PokerSession.league_id, db.func.count(PokerSession.id))
.filter(PokerSession.league_id.in_(league_ids))
.group_by(PokerSession.league_id)
.all()
):
session_counts[row[0]] = row[1]
total_pages = max(1, (total + per_page - 1) // per_page)
return render_template(
"admin/leagues.html",
leagues=league_list,
owners_by_id=owners_by_id,
member_counts=member_counts,
session_counts=session_counts,
search=search,
visibility=visibility,
show_archived=show_archived,
page=page,
total=total,
total_pages=total_pages,
)
@site_admin_bp.get("/leagues/<league_id>")
@site_admin_required
def league_detail(league_id: str):
from ..db_models import League, LedgerEvent, LeagueMembership, Player, PokerSession, User
league = db.session.get(League, league_id)
if league is None:
flash("League not found.", "error")
return redirect(url_for("site_admin.leagues"))
memberships = (
db.session.query(LeagueMembership, User)
.join(User, User.id == LeagueMembership.user_id)
.filter(LeagueMembership.league_id == league_id)
.order_by(LeagueMembership.created_at.asc())
.all()
)
owner = db.session.get(User, league.created_by_user_id)
session_count = db.session.query(db.func.count(PokerSession.id)).filter(
PokerSession.league_id == league_id
).scalar() or 0
open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(
PokerSession.league_id == league_id,
PokerSession.status == "open",
).scalar() or 0
event_count = db.session.query(db.func.count(LedgerEvent.id)).filter(
LedgerEvent.league_id == league_id,
LedgerEvent.voided_at.is_(None),
).scalar() or 0
player_count = db.session.query(db.func.count(Player.id)).filter(
Player.league_id == league_id
).scalar() or 0
return render_template(
"admin/league_detail.html",
league=league,
memberships=memberships,
owner=owner,
session_count=session_count,
open_sessions=open_sessions,
event_count=event_count,
player_count=player_count,
)
@site_admin_bp.post("/leagues/<league_id>/invite")
@site_admin_required
def admin_invite_member(league_id: str):
from flask import current_app
from ..auth import generate_invite_token
from ..db_models import League
from ..emails import send_league_invite
from ..repositories.leagues import find_membership, find_user_by_email
league = db.session.get(League, league_id)
if league is None:
flash("League not found.", "error")
return redirect(url_for("site_admin.leagues"))
email = normalize_email(request.form.get("email", ""))
role = request.form.get("role", "manager").strip()
if not email or "@" not in email:
flash("Enter a valid email address.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
if role not in ("manager", "viewer"):
flash("Invalid role.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
existing_user = find_user_by_email(email)
if existing_user:
existing_membership = find_membership(league.id, existing_user.id)
if existing_membership:
flash(f"{email} is already a member of this league.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
token = generate_invite_token(league.id, email, role, current_user_id() or "")
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
invite_url = f"{base_url}{url_for('account.accept_invite', token=token)}"
try:
send_league_invite(email, league.name, invite_url, current_user_id() or "")
flash(f"Invitation sent to {email}.", "success")
except Exception:
flash(f"Email not configured. Invite link (copy manually): {invite_url}", "info")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
@site_admin_bp.post("/leagues/<league_id>/members/<user_id>/remove")
@site_admin_required
def admin_remove_member(league_id: str, user_id: str):
from ..db_models import League, LeagueMembership
from ..repositories.leagues import remove_league_member
league = db.session.get(League, league_id)
if league is None:
flash("League not found.", "error")
return redirect(url_for("site_admin.leagues"))
membership = LeagueMembership.query.filter_by(
league_id=league_id,
user_id=user_id,
disabled_at=None,
).one_or_none()
if membership is None:
flash("Member not found.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
if membership.role == "owner":
flash("Cannot remove the league owner. Transfer ownership first.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
remove_league_member(league_id, user_id)
db.session.commit()
flash("Member removed.", "success")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
@site_admin_bp.post("/leagues/<league_id>/transfer")
@site_admin_required
def admin_transfer_ownership(league_id: str):
from ..db_models import League
from ..repositories.leagues import transfer_league_ownership
league = db.session.get(League, league_id)
if league is None:
flash("League not found.", "error")
return redirect(url_for("site_admin.leagues"))
new_owner_id = request.form.get("new_owner_user_id", "").strip()
if not new_owner_id:
flash("Select a member to transfer ownership to.", "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
try:
transfer_league_ownership(league_id, new_owner_id)
db.session.commit()
flash("Ownership transferred.", "success")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("site_admin.league_detail", league_id=league_id))
+562
View File
@@ -0,0 +1,562 @@
/* ================================================================
ADMIN SHELL — left sidebar layout
================================================================ */
.admin-html, .admin-body {
margin: 0;
height: 100%;
}
.admin-body {
display: flex;
min-height: 100vh;
background: var(--bg);
color: var(--text-body);
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- sidebar ---- */
.adm-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
overflow-y: auto;
}
.adm-sidebar__logo {
padding: 20px 16px 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 2px;
}
.adm-sidebar__wordmark {
font-size: 13px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -.01em;
}
.adm-sidebar__tag {
font-size: 10px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-chip);
border: 1px solid var(--accent-chip-bd);
border-radius: 4px;
padding: 1px 5px;
display: inline-block;
width: fit-content;
}
.adm-nav {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.adm-nav__section {
font-size: 10px;
font-weight: 600;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--faintest);
padding: 12px 8px 4px;
}
.adm-nav__link {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--r-pill);
color: var(--text-2);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: background .12s, color .12s;
}
.adm-nav__link:hover {
background: var(--surface-raised);
color: var(--text);
}
.adm-nav__link.is-active {
background: var(--accent-chip);
color: var(--accent);
font-weight: 600;
}
.adm-nav__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: .7;
}
.adm-nav__link.is-active .adm-nav__icon { opacity: 1; }
/* ---- main content ---- */
.adm-main {
margin-left: 220px;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ---- header ---- */
.adm-header {
height: 60px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
gap: 16px;
background: var(--surface);
position: sticky;
top: 0;
z-index: 50;
}
.adm-header__left {
display: flex;
align-items: center;
min-width: 0;
}
.adm-header__title {
font-size: 16px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -.01em;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adm-header__breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
min-width: 0;
}
.adm-header__breadcrumb a {
color: var(--faintest);
text-decoration: none;
font-weight: 500;
transition: color .12s;
white-space: nowrap;
}
.adm-header__breadcrumb a:hover { color: var(--accent); }
.adm-header__sep { color: var(--border-strong); flex-shrink: 0; }
.adm-header__title.adm-header__breadcrumb-end {
font-size: 14px;
font-weight: 600;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* breadcrumb leaf node */
.adm-header__breadcrumb .adm-header__title {
font-size: 14px;
font-weight: 600;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
.adm-header__right {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.adm-header__user {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--faintest);
font-family: var(--font-mono);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.adm-header__back {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-2);
text-decoration: none;
border: 1px solid var(--border-strong);
border-radius: var(--r-pill);
padding: 4px 10px;
transition: border-color .12s, color .12s, background .12s;
white-space: nowrap;
}
.adm-header__back:hover {
color: var(--text-strong);
border-color: var(--border-hi);
background: var(--surface-raised);
}
.adm-content {
padding: 28px;
max-width: 1200px;
}
/* ---- flash messages ---- */
.adm-flash-stack {
padding: 0 28px 0;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.adm-flash {
padding: 10px 14px;
border-radius: var(--r-sm);
font-size: 13px;
}
.adm-flash.success { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
.adm-flash.error { background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); }
.adm-flash.info { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
/* ---- stat sections ---- */
.adm-stat-section {
margin-bottom: 24px;
}
.adm-stat-section__label {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--faintest);
margin-bottom: 10px;
padding-left: 2px;
}
.adm-stat-section__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 10px;
}
/* ---- stat grid (legacy, still used on other pages) ---- */
.adm-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.adm-stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 16px 18px;
}
.adm-stat--accent {
border-left: 3px solid var(--accent);
padding-left: 15px;
background: var(--accent-tint);
}
.adm-stat--pos {
border-left: 3px solid var(--pos);
padding-left: 15px;
background: var(--pos-tint);
}
.adm-stat--warn {
border-left: 3px solid var(--warn);
padding-left: 15px;
background: var(--warn-tint);
}
.adm-stat__label {
font-size: 11px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--faintest);
margin-bottom: 6px;
}
.adm-stat__val {
font-size: 28px;
font-weight: 700;
color: var(--text-strong);
line-height: 1;
}
.adm-stat__sub {
font-size: 11px;
color: var(--faintest);
margin-top: 4px;
}
.adm-stat__sub b { color: var(--text-2); }
/* ---- section heading ---- */
.adm-section {
margin-bottom: 28px;
}
.adm-section__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.adm-section__title {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
/* ---- table ---- */
.adm-table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface);
}
.adm-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.adm-table th {
padding: 9px 14px;
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--faintest);
border-bottom: 1px solid var(--border);
white-space: nowrap;
background: var(--surface-head);
}
.adm-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--divider);
color: var(--text-2);
vertical-align: middle;
}
.adm-table tr:last-child td { border-bottom: none; }
.adm-table tr:hover td { background: var(--surface-raised); }
.adm-table a { color: var(--accent); text-decoration: none; }
.adm-table a:hover { text-decoration: underline; }
.adm-table .adm-mono { font-family: var(--font-mono); font-size: 12px; }
.adm-table .cell-muted { color: var(--faintest); }
/* ---- pill badges ---- */
.adm-pill {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 99px;
white-space: nowrap;
}
.adm-pill--active { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
.adm-pill--disabled{ background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); }
.adm-pill--admin { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
.adm-pill--open { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
.adm-pill--public { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
.adm-pill--private { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
.adm-pill--owner { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); }
/* ---- search bar ---- */
.adm-search {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.adm-search input {
flex: 1;
min-width: 0;
padding: 8px 12px;
background: var(--field);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.adm-search input:focus {
outline: none;
border-color: var(--accent);
}
/* ---- buttons ---- */
.adm-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
transition: opacity .12s, background .12s;
white-space: nowrap;
}
.adm-btn:hover { opacity: .85; }
.adm-btn--primary { background: var(--accent); color: var(--accent-ink); }
.adm-btn--ghost { background: transparent; color: var(--text-2); border-color: var(--border-strong); }
.adm-btn--ghost:hover { background: var(--surface-raised); opacity: 1; }
.adm-btn--danger { background: var(--neg-tint); color: var(--neg); border-color: var(--neg-tint-bd); }
.adm-btn--warn { background: var(--warn-tint); color: var(--warn); border-color: var(--warn-tint-bd); }
.adm-btn--sm { padding: 4px 10px; font-size: 11px; }
/* ---- card ---- */
.adm-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 20px;
margin-bottom: 20px;
}
.adm-card__title {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.adm-kv { display: grid; gap: 8px; }
.adm-kv-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 12px;
font-size: 13px;
}
.adm-kv-row dt { color: var(--faintest); font-size: 12px; }
.adm-kv-row dd { margin: 0; color: var(--text-2); word-break: break-all; }
.adm-kv-row dd b { color: var(--text-strong); font-weight: 600; }
/* ---- action group ---- */
.adm-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
/* ---- reset link box ---- */
.adm-reset-box {
margin-top: 12px;
padding: 12px 14px;
background: var(--warn-tint);
border: 1px solid var(--warn-tint-bd);
border-radius: var(--r-sm);
}
.adm-reset-box__label {
font-size: 11px;
font-weight: 600;
color: var(--warn);
letter-spacing: .04em;
text-transform: uppercase;
margin-bottom: 6px;
}
.adm-reset-box__url {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
word-break: break-all;
user-select: all;
}
/* ---- pagination ---- */
.adm-pagination {
display: flex;
align-items: center;
gap: 6px;
margin-top: 16px;
font-size: 12px;
color: var(--faintest);
}
.adm-pagination a {
padding: 4px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--r-pill);
color: var(--text-2);
text-decoration: none;
}
.adm-pagination a:hover { background: var(--surface-raised); }
.adm-pagination span { padding: 4px 10px; color: var(--faintest); }
/* ---- inline form ---- */
.adm-inline-form { display: inline; }
.adm-pass-form {
display: flex;
gap: 8px;
align-items: flex-end;
margin-top: 8px;
}
.adm-pass-form input {
padding: 6px 10px;
background: var(--field);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
color: var(--text);
font-size: 12px;
font-family: inherit;
min-width: 220px;
}
.adm-pass-form input:focus { outline: none; border-color: var(--accent); }
/* ---- chart layout ---- */
.adm-chart-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 28px;
}
@media (max-width: 768px) {
.adm-chart-row { grid-template-columns: 1fr; }
}
.adm-chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 16px 18px 14px;
}
.adm-chart-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.adm-chart-card__title {
font-size: 12px;
font-weight: 700;
color: var(--text-strong);
text-transform: uppercase;
letter-spacing: .05em;
line-height: 1.2;
}
.adm-chart-card__sub {
font-size: 11px;
color: var(--faintest);
margin-top: 3px;
}
.adm-chart-card__meta {
font-size: 11px;
color: var(--faintest);
white-space: nowrap;
margin-top: 2px;
}
.adm-chart-card__meta b { color: var(--text-2); }
+7
View File
@@ -597,6 +597,13 @@ h3 {
outline: none;
appearance: none;
}
.form-card select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1l4.5 4.5L10 1' stroke='%2366646f' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 34px;
cursor: pointer;
}
.form-card input:focus, .form-card select:focus, .form-card textarea:focus {
border-color: var(--accent-chip-bd);
box-shadow: 0 0 0 3px var(--accent-a22);
+6
View File
@@ -78,6 +78,12 @@
--w-admin:1580px;
--gutter: clamp(16px, 4vw, 40px);
/* ---- typography ---- */
--font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: var(--font-ui);
--font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Roboto Mono", "Liberation Mono", monospace;
--font-money: var(--font-ui);
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
--text-muted: #bcbac6;
--line: #26262f;
+95
View File
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en" class="admin-html">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock %} · boker admin</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
<meta name="robots" content="noindex,nofollow">
</head>
<body class="admin-body">
<aside class="adm-sidebar">
<div class="adm-sidebar__logo">
<span class="adm-sidebar__wordmark">myboker.org</span>
<span class="adm-sidebar__tag">Admin</span>
</div>
<nav class="adm-nav">
<span class="adm-nav__section">Overview</span>
<a class="adm-nav__link {{ 'is-active' if request.endpoint == 'site_admin.dashboard' }}"
href="{{ url_for('site_admin.dashboard') }}">
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="6" height="6" rx="1.5"/>
<rect x="9" y="1" width="6" height="6" rx="1.5"/>
<rect x="1" y="9" width="6" height="6" rx="1.5"/>
<rect x="9" y="9" width="6" height="6" rx="1.5"/>
</svg>
Dashboard
</a>
<span class="adm-nav__section">Manage</span>
<a class="adm-nav__link {{ 'is-active' if request.endpoint and request.endpoint.startswith('site_admin.user') }}"
href="{{ url_for('site_admin.users') }}">
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="8" cy="5" r="3"/>
<path d="M2 14c0-3.314 2.686-5 6-5s6 1.686 6 5" stroke-linecap="round"/>
</svg>
Users
</a>
<a class="adm-nav__link {{ 'is-active' if request.endpoint and request.endpoint.startswith('site_admin.league') }}"
href="{{ url_for('site_admin.leagues') }}">
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="8" cy="8" r="6.5"/>
<path d="M5 8h6M8 5v6"/>
</svg>
Leagues
</a>
</nav>
</aside>
<div class="adm-main">
<header class="adm-header">
<div class="adm-header__left">
{% block breadcrumb %}
<h1 class="adm-header__title">{% block page_title %}{% endblock %}</h1>
{% endblock %}
</div>
<div class="adm-header__right">
<span class="adm-header__user">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.5">
<circle cx="8" cy="5" r="3"/>
<path d="M2 14c0-3.314 2.686-5 6-5s6 1.686 6 5" stroke-linecap="round"/>
</svg>
{{ current_user_id }}
</span>
<a class="adm-header__back" href="{{ url_for('public.home') }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10 3L5 8l5 5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to site
</a>
</div>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="adm-flash-stack">
{% for category, message in messages %}
<div class="adm-flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="adm-content">
{% block content %}{% endblock %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+269
View File
@@ -0,0 +1,269 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="adm-stat-section">
<div class="adm-stat-section__label">Users</div>
<div class="adm-stat-section__grid">
<div class="adm-stat">
<div class="adm-stat__label">Total users</div>
<div class="adm-stat__val">{{ stats.total_users }}</div>
<div class="adm-stat__sub"><b>{{ stats.new_users_7d }}</b> new this week · <b>{{ stats.new_users_30d }}</b> this month</div>
</div>
<div class="adm-stat">
<div class="adm-stat__label">Active accounts</div>
<div class="adm-stat__val">{{ stats.active_users }}</div>
<div class="adm-stat__sub"><b>{{ stats.disabled_users }}</b> disabled · <b>{{ stats.admin_count }}</b> admin{% if stats.admin_count != 1 %}s{% endif %}</div>
</div>
<div class="adm-stat adm-stat--accent">
<div class="adm-stat__label">Daily active</div>
<div class="adm-stat__val">{{ stats.dau }}</div>
<div class="adm-stat__sub">logged in today</div>
</div>
<div class="adm-stat adm-stat--accent">
<div class="adm-stat__label">Weekly active</div>
<div class="adm-stat__val">{{ stats.wau }}</div>
<div class="adm-stat__sub">past 7 days</div>
</div>
<div class="adm-stat adm-stat--accent">
<div class="adm-stat__label">Monthly active</div>
<div class="adm-stat__val">{{ stats.mau }}</div>
<div class="adm-stat__sub">past 30 days</div>
</div>
</div>
</div>
<div class="adm-stat-section">
<div class="adm-stat-section__label">Leagues</div>
<div class="adm-stat-section__grid">
<div class="adm-stat adm-stat--pos">
<div class="adm-stat__label">Active leagues</div>
<div class="adm-stat__val">{{ stats.active_leagues }}</div>
<div class="adm-stat__sub"><b>{{ stats.public_leagues }}</b> public · <b>{{ stats.new_leagues_30d }}</b> new this month</div>
</div>
<div class="adm-stat adm-stat--pos">
<div class="adm-stat__label">Avg sessions / league</div>
<div class="adm-stat__val">{{ stats.avg_sessions_per_league }}</div>
<div class="adm-stat__sub">across all leagues</div>
</div>
<div class="adm-stat adm-stat--pos">
<div class="adm-stat__label">Avg buyin / league</div>
<div class="adm-stat__val">{{ stats.avg_buyin_cents | money }}</div>
<div class="adm-stat__sub">total buyins per league</div>
</div>
</div>
</div>
<div class="adm-stat-section">
<div class="adm-stat-section__label">Activity</div>
<div class="adm-stat-section__grid">
<div class="adm-stat adm-stat--warn">
<div class="adm-stat__label">Sessions</div>
<div class="adm-stat__val">{{ stats.total_sessions }}</div>
<div class="adm-stat__sub"><b>{{ stats.open_sessions }}</b> currently open</div>
</div>
<div class="adm-stat adm-stat--warn">
<div class="adm-stat__label">Ledger events</div>
<div class="adm-stat__val">{{ stats.total_events }}</div>
<div class="adm-stat__sub">across all leagues</div>
</div>
</div>
</div>
<div class="adm-chart-row">
<div class="adm-chart-card">
<div class="adm-chart-card__head">
<div>
<div class="adm-chart-card__title">User activity</div>
<div class="adm-chart-card__sub">Logins &amp; new signups — last 30 days</div>
</div>
<span class="adm-chart-card__meta">DAU <b>{{ stats.dau }}</b> · MAU <b>{{ stats.mau }}</b></span>
</div>
<canvas id="userActivityChart" height="110"></canvas>
</div>
<div class="adm-chart-card">
<div class="adm-chart-card__head">
<div>
<div class="adm-chart-card__title">Session frequency</div>
<div class="adm-chart-card__sub">Sessions played per day — last 30 days</div>
</div>
<span class="adm-chart-card__meta">Total <b>{{ stats.total_sessions }}</b></span>
</div>
<canvas id="sessionActivityChart" height="110"></canvas>
</div>
</div>
<div class="adm-section">
<div class="adm-section__head">
<span class="adm-section__title">Recent sign-ups</span>
<a class="adm-btn adm-btn--ghost adm-btn--sm" href="{{ url_for('site_admin.users') }}">View all users</a>
</div>
{% if recent_users %}
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Email</th>
<th>Joined</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for user in recent_users %}
<tr>
<td><a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>{% if user.is_site_admin %} <span class="adm-pill adm-pill--admin">admin</span>{% endif %}</td>
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
<td>
{% if user.disabled_at %}
<span class="adm-pill adm-pill--disabled">Disabled</span>
{% else %}
<span class="adm-pill adm-pill--active">Active</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color:var(--faintest);font-size:13px;">No users yet.</p>
{% endif %}
</div>
<div class="adm-section">
<div class="adm-section__head">
<span class="adm-section__title">Recently created leagues</span>
<a class="adm-btn adm-btn--ghost adm-btn--sm" href="{{ url_for('site_admin.leagues') }}">View all leagues</a>
</div>
{% if recent_leagues %}
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Visibility</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for league in recent_leagues %}
<tr>
<td><a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a>{% if league.archived_at %} <span class="adm-pill adm-pill--archived">archived</span>{% endif %}</td>
<td>
{% set owner = owners_by_id.get(league.created_by_user_id) %}
{% if owner %}
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
{% else %}
<span class="cell-muted"></span>
{% endif %}
</td>
<td>
{% if league.visibility == 'public' %}
<span class="adm-pill adm-pill--public">Public</span>
{% else %}
<span class="adm-pill adm-pill--private">Private</span>
{% endif %}
</td>
<td class="cell-muted">{{ league.created_at.strftime('%b %d, %Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color:var(--faintest);font-size:13px;">No leagues yet.</p>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function() {
var d = {{ chart_data | safe }};
var style = getComputedStyle(document.documentElement);
var accent = style.getPropertyValue('--accent').trim() || '#9b8cf0';
var pos = style.getPropertyValue('--pos').trim() || '#6fc093';
var warn = style.getPropertyValue('--warn').trim() || '#e0b15c';
var faint = style.getPropertyValue('--faintest').trim() || '#66646f';
var border = style.getPropertyValue('--border').trim() || '#26262f';
Chart.defaults.color = faint;
Chart.defaults.font.family = 'inherit';
Chart.defaults.font.size = 11;
new Chart(document.getElementById('userActivityChart'), {
type: 'line',
data: {
labels: d.labels,
datasets: [
{
label: 'Active users',
data: d.logins,
borderColor: accent,
backgroundColor: accent + '18',
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
},
{
label: 'New signups',
data: d.signups,
borderColor: pos,
backgroundColor: 'transparent',
fill: false,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 1.5,
borderDash: [5, 3],
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }
},
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 7, color: faint } },
y: { beginAtZero: true, grid: { color: border }, ticks: { precision: 0, color: faint } }
}
}
});
new Chart(document.getElementById('sessionActivityChart'), {
type: 'bar',
data: {
labels: d.labels,
datasets: [{
label: 'Sessions',
data: d.sessions,
backgroundColor: warn + '55',
borderColor: warn + 'bb',
borderWidth: 1,
borderRadius: 3,
borderSkipped: false,
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }
},
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 7, color: faint } },
y: { beginAtZero: true, grid: { color: border }, ticks: { precision: 0, color: faint } }
}
}
});
})();
</script>
{% endblock %}
+209
View File
@@ -0,0 +1,209 @@
{% extends "admin/base.html" %}
{% block title %}{{ league.name }}{% endblock %}
{% block breadcrumb %}
<div class="adm-header__breadcrumb">
<a href="{{ url_for('site_admin.leagues') }}">Leagues</a>
<span class="adm-header__sep">/</span>
<span class="adm-header__title">{{ league.name }}</span>
</div>
{% endblock %}
{% block content %}
<div class="adm-card">
<div class="adm-card__title">League info</div>
<dl class="adm-kv">
<div class="adm-kv-row">
<dt>Name</dt>
<dd><b>{{ league.name }}</b></dd>
</div>
<div class="adm-kv-row">
<dt>League ID</dt>
<dd class="adm-mono">{{ league.id }}</dd>
</div>
<div class="adm-kv-row">
<dt>Slug / key</dt>
<dd class="adm-mono">{{ league.slug }}-{{ league.public_key }}</dd>
</div>
{% if league.description %}
<div class="adm-kv-row">
<dt>Description</dt>
<dd>{{ league.description }}</dd>
</div>
{% endif %}
<div class="adm-kv-row">
<dt>Visibility</dt>
<dd>
{% if league.visibility == 'public' %}
<span class="adm-pill adm-pill--public">Public</span>
{% else %}
<span class="adm-pill adm-pill--private">Private</span>
{% endif %}
</dd>
</div>
<div class="adm-kv-row">
<dt>Owner</dt>
<dd>
{% if owner %}
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
{% else %}
<span style="color:var(--faintest)">Unknown</span>
{% endif %}
</dd>
</div>
<div class="adm-kv-row">
<dt>Created</dt>
<dd>{{ league.created_at.strftime('%b %d, %Y at %H:%M UTC') }}</dd>
</div>
<div class="adm-kv-row">
<dt>Status</dt>
<dd>
{% if league.archived_at %}
<span class="adm-pill adm-pill--archived">Archived</span>
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">{{ league.archived_at.strftime('%b %d, %Y') }}</span>
{% else %}
<span class="adm-pill adm-pill--active">Active</span>
{% endif %}
</dd>
</div>
</dl>
</div>
<div class="adm-stat-grid" style="margin-bottom:20px;">
<div class="adm-stat">
<div class="adm-stat__label">Members</div>
<div class="adm-stat__val">{{ memberships | length }}</div>
</div>
<div class="adm-stat">
<div class="adm-stat__label">Players</div>
<div class="adm-stat__val">{{ player_count }}</div>
<div class="adm-stat__sub">added to this league</div>
</div>
<div class="adm-stat">
<div class="adm-stat__label">Sessions</div>
<div class="adm-stat__val">{{ session_count }}</div>
<div class="adm-stat__sub"><b>{{ open_sessions }}</b> open</div>
</div>
<div class="adm-stat">
<div class="adm-stat__label">Ledger events</div>
<div class="adm-stat__val">{{ event_count }}</div>
</div>
</div>
{% if not league.archived_at %}
<div style="margin-bottom:20px;">
<a class="adm-btn adm-btn--ghost" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}" target="_blank">
View league →
</a>
</div>
{% endif %}
{% if memberships %}
<div class="adm-card">
<div class="adm-card__title">Members ({{ memberships | length }})</div>
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0 -20px -20px;">
<table class="adm-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Joined league</th>
</tr>
</thead>
<tbody>
{% for membership, user in memberships %}
<tr>
<td>
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
{% if user.disabled_at %}<span class="adm-pill adm-pill--disabled" style="margin-left:4px;">disabled</span>{% endif %}
</td>
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
<td class="cell-muted">{{ membership.created_at.strftime('%b %d, %Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="adm-card">
<div class="adm-card__title">Admin actions</div>
<div style="margin-bottom:20px;">
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Send invite</div>
<form method="post" action="{{ url_for('site_admin.admin_invite_member', league_id=league.id) }}" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:180px;">
<label style="font-size:11px;color:var(--faintest);">Email</label>
<input type="email" name="email" placeholder="user@example.com" required
style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:11px;color:var(--faintest);">Role</label>
<select name="role" style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
<option value="manager">Manager</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button class="adm-btn adm-btn--primary adm-btn--sm" type="submit">Send invite</button>
</form>
</div>
{% set has_non_owners = [] %}
{% for membership, user in memberships %}{% if membership.role != 'owner' %}{% set _ = has_non_owners.append(1) %}{% endif %}{% endfor %}
{% if has_non_owners %}
<div style="margin-bottom:20px;border-top:1px solid var(--border);padding-top:16px;">
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Remove member</div>
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0;">
<table class="adm-table">
<thead>
<tr><th>User</th><th>Role</th><th></th></tr>
</thead>
<tbody>
{% for membership, user in memberships %}
{% if membership.role != 'owner' %}
<tr>
<td><a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a></td>
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
<td>
<form method="post" action="{{ url_for('site_admin.admin_remove_member', league_id=league.id, user_id=user.id) }}" style="margin:0;display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--danger adm-btn--sm" type="submit">Remove</button>
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% set has_managers = [] %}
{% for membership, user in memberships %}{% if membership.role == 'manager' %}{% set _ = has_managers.append((membership, user)) %}{% endif %}{% endfor %}
{% if has_managers %}
<div style="border-top:1px solid var(--border);padding-top:16px;">
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Transfer ownership</div>
<form method="post" action="{{ url_for('site_admin.admin_transfer_ownership', league_id=league.id) }}" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:180px;">
<label style="font-size:11px;color:var(--faintest);">New owner</label>
<select name="new_owner_user_id" style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
{% for membership, user in memberships %}
{% if membership.role == 'manager' %}
<option value="{{ user.id }}">{{ user.email }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<button class="adm-btn adm-btn--warn adm-btn--sm" type="submit">Transfer ownership</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "admin/base.html" %}
{% block title %}Leagues{% endblock %}
{% block page_title %}Leagues <span style="color:var(--faintest);font-weight:400;font-size:12px;margin-left:6px;">{{ total }}</span>{% endblock %}
{% block content %}
<form class="adm-search" method="get">
<input type="search" name="q" value="{{ search }}" placeholder="Search by name or owner email…" autofocus>
<select name="visibility" style="padding:8px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:13px;font-family:inherit;">
<option value="" {% if not visibility %}selected{% endif %}>All visibility</option>
<option value="public" {% if visibility == 'public' %}selected{% endif %}>Public</option>
<option value="private" {% if visibility == 'private' %}selected{% endif %}>Private</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-2);white-space:nowrap;">
<input type="checkbox" name="archived" value="1" {% if show_archived %}checked{% endif %}> Show archived
</label>
<button class="adm-btn adm-btn--primary" type="submit">Filter</button>
{% if search or visibility or show_archived %}
<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.leagues') }}">Clear</a>
{% endif %}
</form>
{% if leagues %}
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Visibility</th>
<th>Members</th>
<th>Sessions</th>
<th>Created</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for league in leagues %}
<tr>
<td><a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
<td>
{% set owner = owners_by_id.get(league.created_by_user_id) %}
{% if owner %}
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
{% else %}
<span class="cell-muted"></span>
{% endif %}
</td>
<td>
{% if league.visibility == 'public' %}
<span class="adm-pill adm-pill--public">Public</span>
{% else %}
<span class="adm-pill adm-pill--private">Private</span>
{% endif %}
</td>
<td class="cell-muted">{{ member_counts.get(league.id, 0) }}</td>
<td class="cell-muted">{{ session_counts.get(league.id, 0) }}</td>
<td class="cell-muted">{{ league.created_at.strftime('%b %d, %Y') }}</td>
<td>
{% if league.archived_at %}
<span class="adm-pill adm-pill--archived">Archived</span>
{% else %}
<span class="adm-pill adm-pill--active">Active</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="adm-pagination">
{% if page > 1 %}
<a href="{{ url_for('site_admin.leagues', q=search, visibility=visibility, archived='1' if show_archived else '', page=page-1) }}">← Prev</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="{{ url_for('site_admin.leagues', q=search, visibility=visibility, archived='1' if show_archived else '', page=page+1) }}">Next →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p style="color:var(--faintest);font-size:13px;">
{% if search or visibility %}No leagues matching those filters.{% else %}No leagues yet.{% endif %}
</p>
{% endif %}
{% endblock %}
+168
View File
@@ -0,0 +1,168 @@
{% extends "admin/base.html" %}
{% block title %}{{ user.email }}{% endblock %}
{% block breadcrumb %}
<div class="adm-header__breadcrumb">
<a href="{{ url_for('site_admin.users') }}">Users</a>
<span class="adm-header__sep">/</span>
<span class="adm-header__title">{{ user.email }}</span>
</div>
{% endblock %}
{% block content %}
<div class="adm-card">
<div class="adm-card__title">Account</div>
<dl class="adm-kv">
<div class="adm-kv-row">
<dt>Email</dt>
<dd><b>{{ user.email }}</b></dd>
</div>
<div class="adm-kv-row">
<dt>User ID</dt>
<dd class="adm-mono">{{ user.id }}</dd>
</div>
<div class="adm-kv-row">
<dt>Joined</dt>
<dd>{{ user.created_at.strftime('%b %d, %Y at %H:%M UTC') }}</dd>
</div>
<div class="adm-kv-row">
<dt>Last login</dt>
<dd>{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}<span style="color:var(--faintest)">Never</span>{% endif %}</dd>
</div>
<div class="adm-kv-row">
<dt>Status</dt>
<dd>
{% if user.disabled_at %}
<span class="adm-pill adm-pill--disabled">Disabled</span>
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">since {{ user.disabled_at.strftime('%b %d, %Y') }}</span>
{% else %}
<span class="adm-pill adm-pill--active">Active</span>
{% endif %}
</dd>
</div>
<div class="adm-kv-row">
<dt>Site admin</dt>
<dd>
{% if user.is_site_admin %}
<span class="adm-pill adm-pill--admin">Yes</span>
{% else %}
<span style="color:var(--faintest)">No</span>
{% endif %}
</dd>
</div>
<div class="adm-kv-row">
<dt>Leagues</dt>
<dd>{{ memberships | length }}</dd>
</div>
</dl>
</div>
<div class="adm-card">
<div class="adm-card__title">Actions</div>
<div class="adm-section" style="margin-bottom:20px;">
<div class="adm-section__title" style="margin-bottom:8px;">Password reset</div>
<div class="adm-actions">
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.send_reset', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--ghost" type="submit"{% if user.disabled_at %} disabled title="Account is disabled"{% endif %}>
Send reset email
</button>
</form>
<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.user_detail', user_id=user.id, show_reset=1) }}">
Generate link (no email)
</a>
</div>
{% if reset_url %}
<div class="adm-reset-box">
<div class="adm-reset-box__label">Reset link — valid 1 hour</div>
<div class="adm-reset-box__url">{{ reset_url }}</div>
</div>
{% endif %}
</div>
<div class="adm-section" style="margin-bottom:20px;">
<div class="adm-section__title" style="margin-bottom:8px;">Set password directly</div>
<form class="adm-pass-form" method="post" action="{{ url_for('site_admin.admin_reset_password', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="password" name="new_password" placeholder="New password (min 8 chars)" autocomplete="new-password">
<button class="adm-btn adm-btn--warn" type="submit">Set password</button>
</form>
</div>
{% if not viewing_self %}
<div class="adm-section" style="margin-bottom:20px;">
<div class="adm-section__title" style="margin-bottom:8px;">Account status</div>
<div class="adm-actions">
{% if user.disabled_at %}
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.enable_user', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--ghost" type="submit">Enable account</button>
</form>
{% else %}
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.disable_user', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--danger" type="submit">Disable account</button>
</form>
{% endif %}
</div>
</div>
<div class="adm-section">
<div class="adm-section__title" style="margin-bottom:8px;">Admin access</div>
<div class="adm-actions">
{% if user.is_site_admin %}
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.revoke_admin', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--danger" type="submit">Revoke admin</button>
</form>
{% else %}
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.grant_admin', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="adm-btn adm-btn--ghost" type="submit">Grant admin</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if memberships %}
<div class="adm-card">
<div class="adm-card__title">League memberships ({{ memberships | length }})</div>
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0 -20px -20px;">
<table class="adm-table">
<thead>
<tr>
<th>League</th>
<th>Role</th>
<th>Visibility</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{% for membership, league in memberships %}
<tr>
<td>
<a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a>
{% if league.archived_at %}<span class="adm-pill adm-pill--archived" style="margin-left:4px;">archived</span>{% endif %}
</td>
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
<td>
{% if league.visibility == 'public' %}
<span class="adm-pill adm-pill--public">Public</span>
{% else %}
<span class="adm-pill adm-pill--private">Private</span>
{% endif %}
</td>
<td class="cell-muted">{{ membership.created_at.strftime('%b %d, %Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "admin/base.html" %}
{% block title %}Users{% endblock %}
{% block page_title %}Users <span style="color:var(--faintest);font-weight:400;font-size:12px;margin-left:6px;">{{ total }}</span>{% endblock %}
{% block content %}
<form class="adm-search" method="get">
<input type="search" name="q" value="{{ search }}" placeholder="Search by email…" autofocus>
<button class="adm-btn adm-btn--primary" type="submit">Search</button>
{% if search %}<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.users') }}">Clear</a>{% endif %}
</form>
{% if users %}
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Email</th>
<th>Joined</th>
<th>Last login</th>
<th>Leagues</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
</td>
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
<td class="cell-muted">
{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y') }}{% else %}—{% endif %}
</td>
<td class="cell-muted">{{ league_counts.get(user.id, 0) }}</td>
<td>
{% if user.disabled_at %}
<span class="adm-pill adm-pill--disabled">Disabled</span>
{% else %}
<span class="adm-pill adm-pill--active">Active</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="adm-pagination">
{% if page > 1 %}
<a href="{{ url_for('site_admin.users', q=search, page=page-1) }}">← Prev</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="{{ url_for('site_admin.users', q=search, page=page+1) }}">Next →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p style="color:var(--faintest);font-size:13px;">
{% if search %}No users matching "{{ search }}".{% else %}No users yet.{% endif %}
</p>
{% endif %}
{% endblock %}
+59 -2
View File
@@ -64,18 +64,33 @@
<span>Email</span>
<input type="email" name="email" placeholder="friend@example.com" required>
</label>
<label style="flex:0 0 130px;">
<label style="flex:0 0 140px;">
<span>Role</span>
<select name="role">
<option value="manager">Manager</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button class="btn btn--primary btn--sm" type="submit" style="flex:0 0 auto;margin-bottom:1px;">Send invite</button>
<button class="btn btn--primary" type="submit" style="flex:0 0 auto;">Send invite</button>
</div>
</form>
</div>
{% if has_managers %}
<div class="panel">
<div class="panel__head">
<div>
<p class="kicker" style="margin:0;">Ownership</p>
<h2 class="panel__title">Transfer league ownership</h2>
</div>
</div>
<div class="panel__body">
<p class="muted-text">Transfer ownership of this league to one of your managers. You will become a manager and lose owner privileges.</p>
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-transfer" style="margin-top:8px;">Transfer ownership</button>
</div>
</div>
{% endif %}
<div class="panel">
<div class="panel__head">
<div>
@@ -96,6 +111,48 @@
</div>
{% if has_managers %}
<div class="modal-backdrop" id="modal-transfer" hidden>
<div class="modal-card">
<div class="modal-card__head">
<h2 style="font-size:18px;margin:0;">Transfer ownership</h2>
</div>
<p class="modal-card__text">
Select a manager to become the new owner. You will become a manager and lose owner privileges. This cannot be undone without their help.<br><br>
Type the league name to confirm:
</p>
<form method="post" action="{{ url_for('leagues.transfer_ownership', league_ref=league.url_ref) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="padding:0 20px 12px;">
<label style="font-size:12px;color:var(--text-2);display:block;margin-bottom:4px;">New owner</label>
<select name="new_owner_user_id" style="width:100%;padding:8px 10px;border:1px solid var(--border-strong);border-radius:var(--r-sm);background:var(--field);color:var(--text);font-family:inherit;font-size:13px;">
{% for membership, member_user in members %}
{% if membership.role == 'manager' %}
<option value="{{ member_user.id }}">{{ member_user.email }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div style="padding:0 20px 16px;">
<input
class="modal-confirm-input"
type="text"
name="confirm_name"
placeholder="{{ league.name }}"
autocomplete="off"
data-match="{{ league.name }}"
data-target="transfer-confirm-btn"
>
</div>
<div class="modal-card__actions">
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
<button class="btn btn--sm btn--danger" id="transfer-confirm-btn" type="submit" disabled>Transfer ownership</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="modal-backdrop" id="modal-archive" hidden>
<div class="modal-card">
<div class="modal-card__head">
+32
View File
@@ -0,0 +1,32 @@
"""add is_site_admin to users
Revision ID: 0003_site_admin
Revises: 0002_league_public_key
Create Date: 2026-06-24
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0003_site_admin"
down_revision = "0002_league_public_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(
sa.Column(
"is_site_admin",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("is_site_admin")