diff --git a/boker/__init__.py b/boker/__init__.py index a1cc031..b68942d 100644 --- a/boker/__init__.py +++ b/boker/__init__.py @@ -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: diff --git a/boker/auth.py b/boker/auth.py index c33f1f1..14c9794 100644 --- a/boker/auth.py +++ b/boker/auth.py @@ -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 diff --git a/boker/config.py b/boker/config.py index 0730954..a5f5c85 100644 --- a/boker/config.py +++ b/boker/config.py @@ -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: diff --git a/boker/db_models.py b/boker/db_models.py index 229ebe9..98e4fc6 100644 --- a/boker/db_models.py +++ b/boker/db_models.py @@ -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): diff --git a/boker/repositories/leagues.py b/boker/repositories/leagues.py index b23dbc3..176c9c4 100644 --- a/boker/repositories/leagues.py +++ b/boker/repositories/leagues.py @@ -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" diff --git a/boker/routes/leagues.py b/boker/routes/leagues.py index 4d46c83..d868fb8 100644 --- a/boker/routes/leagues.py +++ b/boker/routes/leagues.py @@ -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//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//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//archive") @login_required def archive_league(league_ref: str): diff --git a/boker/routes/site_admin.py b/boker/routes/site_admin.py new file mode 100644 index 0000000..1e9f4e7 --- /dev/null +++ b/boker/routes/site_admin.py @@ -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/") +@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//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//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//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//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//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//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/") +@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//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//members//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//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)) diff --git a/boker/static/css/admin.css b/boker/static/css/admin.css new file mode 100644 index 0000000..2b40188 --- /dev/null +++ b/boker/static/css/admin.css @@ -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); } diff --git a/boker/static/css/style.css b/boker/static/css/style.css index 6d36ff5..bc6975b 100644 --- a/boker/static/css/style.css +++ b/boker/static/css/style.css @@ -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); diff --git a/boker/static/css/tokens.css b/boker/static/css/tokens.css index 00e4681..f434282 100644 --- a/boker/static/css/tokens.css +++ b/boker/static/css/tokens.css @@ -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; diff --git a/boker/templates/admin/base.html b/boker/templates/admin/base.html new file mode 100644 index 0000000..1c0cd90 --- /dev/null +++ b/boker/templates/admin/base.html @@ -0,0 +1,95 @@ + + + + + + {% block title %}Admin{% endblock %} · boker admin + + + + + + + + + +
+
+
+ {% block breadcrumb %} +

{% block page_title %}{% endblock %}

+ {% endblock %} +
+
+ + + + + + {{ current_user_id }} + + + + + + Back to site + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+
+ + +{% block extra_scripts %}{% endblock %} + + diff --git a/boker/templates/admin/dashboard.html b/boker/templates/admin/dashboard.html new file mode 100644 index 0000000..1cb2d71 --- /dev/null +++ b/boker/templates/admin/dashboard.html @@ -0,0 +1,269 @@ +{% extends "admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + +
+ +
+
+
Total users
+
{{ stats.total_users }}
+
{{ stats.new_users_7d }} new this week · {{ stats.new_users_30d }} this month
+
+
+
Active accounts
+
{{ stats.active_users }}
+
{{ stats.disabled_users }} disabled · {{ stats.admin_count }} admin{% if stats.admin_count != 1 %}s{% endif %}
+
+
+
Daily active
+
{{ stats.dau }}
+
logged in today
+
+
+
Weekly active
+
{{ stats.wau }}
+
past 7 days
+
+
+
Monthly active
+
{{ stats.mau }}
+
past 30 days
+
+
+
+ +
+ +
+
+
Active leagues
+
{{ stats.active_leagues }}
+
{{ stats.public_leagues }} public · {{ stats.new_leagues_30d }} new this month
+
+
+
Avg sessions / league
+
{{ stats.avg_sessions_per_league }}
+
across all leagues
+
+
+
Avg buyin / league
+
{{ stats.avg_buyin_cents | money }}
+
total buyins per league
+
+
+
+ +
+ +
+
+
Sessions
+
{{ stats.total_sessions }}
+
{{ stats.open_sessions }} currently open
+
+
+
Ledger events
+
{{ stats.total_events }}
+
across all leagues
+
+
+
+ +
+
+
+
+
User activity
+
Logins & new signups — last 30 days
+
+ DAU {{ stats.dau }} · MAU {{ stats.mau }} +
+ +
+
+
+
+
Session frequency
+
Sessions played per day — last 30 days
+
+ Total {{ stats.total_sessions }} +
+ +
+
+ +
+
+ Recent sign-ups + View all users +
+ {% if recent_users %} +
+ + + + + + + + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailJoinedStatus
{{ user.email }}{% if user.is_site_admin %} admin{% endif %}{{ user.created_at.strftime('%b %d, %Y') }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ {% else %} +

No users yet.

+ {% endif %} +
+ +
+
+ Recently created leagues + View all leagues +
+ {% if recent_leagues %} +
+ + + + + + + + + + + {% for league in recent_leagues %} + + + + + + + {% endfor %} + +
NameOwnerVisibilityCreated
{{ league.name }}{% if league.archived_at %} archived{% endif %} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ league.created_at.strftime('%b %d, %Y') }}
+
+ {% else %} +

No leagues yet.

+ {% endif %} +
+ +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/boker/templates/admin/league_detail.html b/boker/templates/admin/league_detail.html new file mode 100644 index 0000000..0aadc9d --- /dev/null +++ b/boker/templates/admin/league_detail.html @@ -0,0 +1,209 @@ +{% extends "admin/base.html" %} +{% block title %}{{ league.name }}{% endblock %} + +{% block breadcrumb %} +
+ Leagues + / + {{ league.name }} +
+{% endblock %} + +{% block content %} + +
+
League info
+
+
+
Name
+
{{ league.name }}
+
+
+
League ID
+
{{ league.id }}
+
+
+
Slug / key
+
{{ league.slug }}-{{ league.public_key }}
+
+ {% if league.description %} +
+
Description
+
{{ league.description }}
+
+ {% endif %} +
+
Visibility
+
+ {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} +
+
+
+
Owner
+
+ {% if owner %} + {{ owner.email }} + {% else %} + Unknown + {% endif %} +
+
+
+
Created
+
{{ league.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Status
+
+ {% if league.archived_at %} + Archived + {{ league.archived_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
+ +
+
+
Members
+
{{ memberships | length }}
+
+
+
Players
+
{{ player_count }}
+
added to this league
+
+
+
Sessions
+
{{ session_count }}
+
{{ open_sessions }} open
+
+
+
Ledger events
+
{{ event_count }}
+
+
+ +{% if not league.archived_at %} + +{% endif %} + +{% if memberships %} +
+
Members ({{ memberships | length }})
+
+ + + + + + + + + + {% for membership, user in memberships %} + + + + + + {% endfor %} + +
UserRoleJoined league
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {% if user.disabled_at %}disabled{% endif %} + {{ membership.role }}{{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +
+
Admin actions
+ +
+
Send invite
+
+ +
+ + +
+
+ + +
+ +
+
+ + {% 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 %} +
+
Remove member
+
+ + + + + + {% for membership, user in memberships %} + {% if membership.role != 'owner' %} + + + + + + {% endif %} + {% endfor %} + +
UserRole
{{ user.email }}{{ membership.role }} +
+ + +
+
+
+
+ {% endif %} + + {% set has_managers = [] %} + {% for membership, user in memberships %}{% if membership.role == 'manager' %}{% set _ = has_managers.append((membership, user)) %}{% endif %}{% endfor %} + {% if has_managers %} +
+
Transfer ownership
+
+ +
+ + +
+ +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/boker/templates/admin/leagues.html b/boker/templates/admin/leagues.html new file mode 100644 index 0000000..b4a8f75 --- /dev/null +++ b/boker/templates/admin/leagues.html @@ -0,0 +1,90 @@ +{% extends "admin/base.html" %} +{% block title %}Leagues{% endblock %} +{% block page_title %}Leagues {{ total }}{% endblock %} + +{% block content %} + + + +{% if leagues %} +
+ + + + + + + + + + + + + + {% for league in leagues %} + + + + + + + + + + {% endfor %} + +
NameOwnerVisibilityMembersSessionsCreatedStatus
{{ league.name }} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ member_counts.get(league.id, 0) }}{{ session_counts.get(league.id, 0) }}{{ league.created_at.strftime('%b %d, %Y') }} + {% if league.archived_at %} + Archived + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search or visibility %}No leagues matching those filters.{% else %}No leagues yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/user_detail.html b/boker/templates/admin/user_detail.html new file mode 100644 index 0000000..95c27eb --- /dev/null +++ b/boker/templates/admin/user_detail.html @@ -0,0 +1,168 @@ +{% extends "admin/base.html" %} +{% block title %}{{ user.email }}{% endblock %} + +{% block breadcrumb %} +
+ Users + / + {{ user.email }} +
+{% endblock %} + +{% block content %} + +
+
Account
+
+
+
Email
+
{{ user.email }}
+
+
+
User ID
+
{{ user.id }}
+
+
+
Joined
+
{{ user.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Last login
+
{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}Never{% endif %}
+
+
+
Status
+
+ {% if user.disabled_at %} + Disabled + since {{ user.disabled_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
Site admin
+
+ {% if user.is_site_admin %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Leagues
+
{{ memberships | length }}
+
+
+
+ +
+
Actions
+ +
+
Password reset
+
+
+ + +
+ + Generate link (no email) + +
+ {% if reset_url %} +
+
Reset link — valid 1 hour
+
{{ reset_url }}
+
+ {% endif %} +
+ +
+
Set password directly
+
+ + + +
+
+ + {% if not viewing_self %} +
+
Account status
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ +
+
Admin access
+
+ {% if user.is_site_admin %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ {% endif %} +
+ +{% if memberships %} +
+
League memberships ({{ memberships | length }})
+
+ + + + + + + + + + + {% for membership, league in memberships %} + + + + + + + {% endfor %} + +
LeagueRoleVisibilityJoined
+ {{ league.name }} + {% if league.archived_at %}archived{% endif %} + {{ membership.role }} + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/users.html b/boker/templates/admin/users.html new file mode 100644 index 0000000..80cc6c3 --- /dev/null +++ b/boker/templates/admin/users.html @@ -0,0 +1,68 @@ +{% extends "admin/base.html" %} +{% block title %}Users{% endblock %} +{% block page_title %}Users {{ total }}{% endblock %} + +{% block content %} + + + +{% if users %} +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
EmailJoinedLast loginLeaguesStatus
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {{ user.created_at.strftime('%b %d, %Y') }} + {% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y') }}{% else %}—{% endif %} + {{ league_counts.get(user.id, 0) }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search %}No users matching "{{ search }}".{% else %}No users yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/league_settings.html b/boker/templates/league_settings.html index 4f316fb..54ba9cd 100644 --- a/boker/templates/league_settings.html +++ b/boker/templates/league_settings.html @@ -64,18 +64,33 @@ Email -