admin: internal-only superadmin console (users, payments, DB stats)

New independent `admin` service (admin.main:app) on the same image, reusing
app.db/app.models read-only. Never runs migrations or the scheduler; issues
SELECTs only.

- Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by
  default when the password is empty.
- Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network.
- Pages: overview stats, user list + search, per-user history/payment detail,
  DB usage (information_schema size + row estimates).
- Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile
  bakes admin/ into runtime + test stages.
- Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-01 16:08:20 +02:00
parent 8946dee2e0
commit 411094d7b8
20 changed files with 1143 additions and 1 deletions

50
admin/README.md Normal file
View file

@ -0,0 +1,50 @@
# Superadmin console
An **independent, internal-only** web service for the operator: user list with
per-user history + payment status, and database usage stats. Runs in its own
container (`admin` service) off the same image as the main app, reusing
`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`)
that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
## Access model
- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on
the `intranet`/NPM network. Reach it over an SSH tunnel:
```sh
ssh -L 8091:localhost:8091 <vps>
# then open http://localhost:8091
```
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
Empty password ⇒ every login is refused (closed by default). The login sets
a 12-hour signed cookie (`admin_console_session`).
## Pages
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
referral conversions, newest users.
- `/users` — full user table with email search + paging.
- `/users/{id}` — one user: account, computed paid status, Stripe/Polar
linkage, cloud-sync state, referrals sent, feedback votes, digest-email log,
legal acknowledgements.
- `/db` — per-table row estimates and on-disk size (from
`information_schema.tables`), with each table's share of total.
## Deploy (this host runs prod)
Adding a **new** service means `up -d` (a plain `restart` won't create it).
Do NOT run a bare `docker compose up` on this host — always pass the prod
overlay:
```sh
# 1. set ADMIN_CONSOLE_PASSWORD in .env
# 2. build + create just the admin container (leaves app/scheduler/db running)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin
```
## Tests
```sh
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v
```

9
admin/__init__.py Normal file
View file

@ -0,0 +1,9 @@
"""Superadmin console — an independent, internal-only web service.
Runs in its own container (see the `admin` service in docker-compose.yml),
bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the
main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly
separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic
migrations or the app's lifespan. Every DB access is a plain SELECT — the
console never writes.
"""

71
admin/auth.py Normal file
View file

@ -0,0 +1,71 @@
"""Password gate for the superadmin console.
A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at
``/login`` for a signed, time-limited session cookie. There is no user
identity here the console has exactly one principal, "the operator".
The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET``
(falling back to the main app's session secret / token), mirroring the scheme
in ``app.auth`` but under a distinct salt so an app session cookie can never
be replayed against the console and vice-versa.
"""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import get_settings
SESSION_COOKIE_NAME = "admin_console_session"
SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish.
_SALT = "admin-console-session-v1"
def _serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = (
s.ADMIN_CONSOLE_SESSION_SECRET
or s.CASSANDRA_SESSION_SECRET
or s.CASSANDRA_TOKEN
or "dev-insecure-secret"
)
return URLSafeTimedSerializer(secret, salt=_SALT)
def check_password(provided: str) -> bool:
"""Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns
False when the password is unset the console is closed by default so a
fresh deploy can't be logged into without an explicit password."""
expected = get_settings().ADMIN_CONSOLE_PASSWORD
if not expected:
return False
return secrets.compare_digest(provided.encode(), expected.encode())
def sign_session() -> str:
"""Signed value proving a successful password login. Carries no identity
beyond a version marker the console has a single principal."""
return _serializer().dumps({"v": 1})
def verify_session(cookie: str) -> bool:
try:
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
return data.get("v") == 1
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return False
def require_admin(request: Request) -> None:
"""FastAPI dependency guarding every console page. Valid cookie → passes;
otherwise 303 /login (browser) so the operator lands on the form."""
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie and verify_session(cookie):
return
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="Login required",
headers={"Location": "/login"},
)

140
admin/main.py Normal file
View file

@ -0,0 +1,140 @@
"""Superadmin console FastAPI app.
Independent of ``app.main``: it wires only its own routes and reuses
``app.db``'s session factory for read-only queries. No lifespan, no
migrations, no scheduler.
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from admin import auth, queries
from app.db import get_session
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
def _fmt_bytes(n: int | None) -> str:
if not n:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = float(n)
for u in units:
if size < 1024 or u == units[-1]:
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
size /= 1024
return f"{n} B"
def _fmt_dt(dt: datetime | None) -> str:
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
templates.env.filters["bytes"] = _fmt_bytes
templates.env.filters["dt"] = _fmt_dt
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
async def healthz() -> str:
return "ok"
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_form(request: Request):
# Already signed in? Skip the form.
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
if cookie and auth.verify_session(cookie):
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_submit(request: Request, password: str = Form(...)):
if not auth.check_password(password):
return templates.TemplateResponse(
request, "login.html", {"error": "Incorrect password."},
status_code=401,
)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(
auth.SESSION_COOKIE_NAME,
auth.sign_session(),
max_age=auth.SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# secure=False: the console is served over http on a localhost SSH
# tunnel, never public TLS. A secure cookie would never be sent.
secure=False,
)
return resp
@app.get("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
return resp
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.overview_stats(session)
recent, _ = await queries.list_users(session, limit=10)
return templates.TemplateResponse(
request, "overview.html", {"stats": stats, "recent": recent},
)
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def users_list(
request: Request,
session: AsyncSession = Depends(get_session),
q: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
):
per_page = 200
offset = (page - 1) * per_page
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
return templates.TemplateResponse(
request, "users.html",
{"rows": rows, "total": total, "q": q or "",
"page": page, "per_page": per_page},
)
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def user_detail(
request: Request, user_id: int,
session: AsyncSession = Depends(get_session),
):
detail = await queries.user_detail(session, user_id)
if detail is None:
return templates.TemplateResponse(
request, "not_found.html", {"user_id": user_id}, status_code=404,
)
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.db_stats(session)
return templates.TemplateResponse(request, "db.html", {"stats": stats})

266
admin/queries.py Normal file
View file

@ -0,0 +1,266 @@
"""Read-only queries backing the console. Every function issues SELECTs
only the console never mutates the database.
Payment status is computed with the app's own ``paid_status`` so the console
never diverges from what the app considers "paid".
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import (
EmailSend,
PortfolioSync,
Referral,
StrategicLogFeedback,
User,
UserAcknowledgement,
)
from app.services.access import paid_status
async def overview_stats(session: AsyncSession) -> dict:
"""Headline counts for the landing page: totals, tier split, growth,
paid-active, engagement."""
now = utcnow()
d7 = now - timedelta(days=7)
d30 = now - timedelta(days=30)
total = await session.scalar(select(func.count(User.id))) or 0
tier_rows = (await session.execute(
select(User.tier, func.count(User.id)).group_by(User.tier)
)).all()
tiers = {tier: n for tier, n in tier_rows}
signups_7d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d7)
) or 0
signups_30d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d30)
) or 0
active_30d = await session.scalar(
select(func.count(User.id)).where(User.last_login_at >= d30)
) or 0
# Paid-active = subscribed tier OR a live credit window. The two are
# counted disjointly (a credit user whose tier is already paid is only
# counted once, under tier) so the sum can't double-count.
paid_by_tier = await session.scalar(
select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise")))
) or 0
credit_active = await session.scalar(
select(func.count(User.id)).where(
User.credit_until.is_not(None),
User.credit_until > now,
User.tier.not_in(("paid", "enterprise")),
)
) or 0
sync_enabled = await session.scalar(
select(func.count(PortfolioSync.user_id))
) or 0
referrals_total = await session.scalar(select(func.count(Referral.id))) or 0
referrals_converted = await session.scalar(
select(func.count(Referral.id)).where(Referral.converted_at.is_not(None))
) or 0
return {
"total_users": total,
"tiers": tiers,
"free": tiers.get("free", 0),
"paid": tiers.get("paid", 0),
"enterprise": tiers.get("enterprise", 0),
"paid_active": paid_by_tier + credit_active,
"credit_active": credit_active,
"signups_7d": signups_7d,
"signups_30d": signups_30d,
"active_30d": active_30d,
"sync_enabled": sync_enabled,
"referrals_total": referrals_total,
"referrals_converted": referrals_converted,
}
async def list_users(
session: AsyncSession,
q: str | None = None,
limit: int = 200,
offset: int = 0,
) -> tuple[list[dict], int]:
"""User rows for the table view, newest first. Optional ``q`` filters by
email substring. Returns (rows, total_matching)."""
stmt = select(User)
count_stmt = select(func.count(User.id))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(User.email.like(like))
count_stmt = count_stmt.where(User.email.like(like))
total = await session.scalar(count_stmt) or 0
users = (await session.execute(
stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
)).scalars().all()
if not users:
return [], total
ids = [u.id for u in users]
# One grouped query each for the per-user badges, merged in Python —
# avoids an N+1 without a wide join fan-out.
sync_ids = set((await session.execute(
select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids))
)).scalars().all())
ref_counts = dict((await session.execute(
select(Referral.referrer_user_id, func.count(Referral.id))
.where(Referral.referrer_user_id.in_(ids))
.group_by(Referral.referrer_user_id)
)).all())
rows: list[dict] = []
for u in users:
ps = paid_status(u)
rows.append({
"id": u.id,
"email": u.email,
"tier": u.tier,
"paid_active": ps.active,
"paid_source": ps.source,
"credit_days": ps.days_remaining,
"created_at": u.created_at,
"last_login_at": u.last_login_at,
"lang": u.lang,
"has_sync": u.id in sync_ids,
"referrals": ref_counts.get(u.id, 0),
"on_stripe": bool(u.stripe_customer_id),
"on_polar": bool(u.polar_customer_id),
"trialing": u.stripe_trial_end_at is not None,
})
return rows, total
async def user_detail(session: AsyncSession, user_id: int) -> dict | None:
"""Everything the console shows for one user: the row itself, computed
payment status, billing linkage, and joined activity history."""
user = await session.get(User, user_id)
if user is None:
return None
ps = paid_status(user)
referred_by = None
if user.referred_by_user_id:
ref = await session.get(User, user.referred_by_user_id)
referred_by = {"id": ref.id, "email": ref.email} if ref else None
# Referrals this user sent (with conversion state).
sent = (await session.execute(
select(Referral, User.email)
.join(User, User.id == Referral.referred_user_id)
.where(Referral.referrer_user_id == user_id)
.order_by(Referral.created_at.desc())
)).all()
referrals_sent = [{
"referred_email": email,
"created_at": r.created_at,
"converted_at": r.converted_at,
} for r, email in sent]
# Digest email delivery log (most recent first, capped).
email_rows = (await session.execute(
select(EmailSend).where(EmailSend.user_id == user_id)
.order_by(EmailSend.sent_at.desc()).limit(25)
)).scalars().all()
emails = [{
"kind": e.kind, "sent_at": e.sent_at,
"status": e.status, "error": e.error,
} for e in email_rows]
# Legal acknowledgements (evidentiary audit trail).
ack_rows = (await session.execute(
select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id)
.order_by(UserAcknowledgement.accepted_at.desc())
)).scalars().all()
acks = [{
"version": a.version, "lang": a.lang, "accepted_at": a.accepted_at,
} for a in ack_rows]
# Feedback votes cast on strategic logs.
fb_rows = (await session.execute(
select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id))
.where(StrategicLogFeedback.user_id == user_id)
.group_by(StrategicLogFeedback.vote)
)).all()
feedback = {vote: n for vote, n in fb_rows}
sync = await session.get(PortfolioSync, user_id)
return {
"user": user,
"paid": ps,
"referred_by": referred_by,
"referrals_sent": referrals_sent,
"emails": emails,
"acks": acks,
"feedback": feedback,
"sync": {
"enabled": sync is not None,
"updated_at": sync.updated_at if sync else None,
"version": sync.version if sync else None,
},
}
async def db_stats(session: AsyncSession) -> dict:
"""Per-table size and row estimates for the current schema.
On MariaDB this reads ``information_schema.tables`` (row counts are the
engine's estimate for InnoDB, sizes are exact). On sqlite (the test
backend) there is no such view, so it falls back to real ``COUNT(*)``
per table with sizes reported as 0.
"""
dialect = session.bind.dialect.name
if dialect == "sqlite":
from app.db import Base
tables = []
for tbl in Base.metadata.sorted_tables:
n = await session.scalar(select(func.count()).select_from(tbl)) or 0
tables.append({
"name": tbl.name, "rows": n,
"data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0,
})
tables.sort(key=lambda t: t["rows"], reverse=True)
return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)}
rows = (await session.execute(text(
"SELECT table_name, table_rows, data_length, index_length "
"FROM information_schema.tables "
"WHERE table_schema = DATABASE() "
"ORDER BY (data_length + index_length) DESC"
))).all()
tables = []
total_bytes = 0
total_rows = 0
for name, tr, data_len, idx_len in rows:
data_len = int(data_len or 0)
idx_len = int(idx_len or 0)
tot = data_len + idx_len
total_bytes += tot
total_rows += int(tr or 0)
tables.append({
"name": name, "rows": int(tr or 0),
"data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot,
})
for t in tables:
t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0
return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows}

80
admin/templates/base.html Normal file
View file

@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}superadmin{% endblock %} · read.markets</title>
<style>
:root {
--bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
--muted:#8b949e; --accent:#58a6ff; --good:#3fb950; --warn:#d29922;
--bad:#f85149; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.5 var(--mono); }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
header { display:flex; align-items:center; gap:1.5rem; padding:.75rem 1.25rem;
background:var(--panel); border-bottom:1px solid var(--border); }
header .brand { font-weight:700; color:#fff; }
header nav { display:flex; gap:1rem; }
header .spacer { flex:1; }
main { padding:1.25rem; max-width:1200px; margin:0 auto; }
h1 { font-size:1.25rem; margin:0 0 1rem; }
h2 { font-size:1rem; color:var(--muted); text-transform:uppercase;
letter-spacing:.05em; margin:1.5rem 0 .5rem; }
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:.75rem; }
.card { background:var(--panel); border:1px solid var(--border);
border-radius:6px; padding:.75rem 1rem; }
.card .n { font-size:1.6rem; font-weight:700; color:#fff; }
.card .l { color:var(--muted); font-size:.8rem; }
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
th,td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border);
white-space:nowrap; }
th { color:var(--muted); font-weight:600; font-size:.8rem;
text-transform:uppercase; letter-spacing:.04em; }
tr:hover td { background:#1c2230; }
.pill { display:inline-block; padding:.05rem .5rem; border-radius:999px;
font-size:.75rem; border:1px solid var(--border); }
.pill.good { color:var(--good); border-color:#238636; }
.pill.warn { color:var(--warn); border-color:#9e6a03; }
.pill.bad { color:var(--bad); border-color:#a1281f; }
.pill.mute { color:var(--muted); }
.muted { color:var(--muted); }
.bar { height:8px; background:#21262d; border-radius:4px; overflow:hidden;
min-width:80px; }
.bar > span { display:block; height:100%; background:var(--accent); }
form.search { margin:.5rem 0; }
input[type=text],input[type=password] {
background:#0d1117; border:1px solid var(--border); color:var(--fg);
padding:.4rem .6rem; border-radius:6px; font:inherit; }
button { background:#238636; color:#fff; border:0; padding:.45rem .9rem;
border-radius:6px; font:inherit; cursor:pointer; }
button:hover { background:#2ea043; }
.kv { display:grid; grid-template-columns:180px 1fr; gap:.25rem 1rem; }
.kv dt { color:var(--muted); }
.kv dd { margin:0; }
.login { max-width:340px; margin:12vh auto; background:var(--panel);
border:1px solid var(--border); border-radius:8px; padding:1.5rem; }
.err { color:var(--bad); margin:.5rem 0; }
</style>
</head>
<body>
{% if show_nav|default(true) %}
<header>
<span class="brand">read.markets · superadmin</span>
<nav>
<a href="/">Overview</a>
<a href="/users">Users</a>
<a href="/db">Database</a>
</nav>
<span class="spacer"></span>
<a href="/logout">Log out</a>
</header>
{% endif %}
<main>{% block body %}{% endblock %}</main>
</body>
</html>

31
admin/templates/db.html Normal file
View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}database{% endblock %}
{% block body %}
<h1>Database usage</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_bytes|bytes }}</div><div class="l">Total size</div></div>
<div class="card"><div class="n">{{ "{:,}".format(stats.total_rows) }}</div><div class="l">Total rows (est.)</div></div>
<div class="card"><div class="n">{{ stats.tables|length }}</div><div class="l">Tables</div></div>
</div>
<h2>Per table</h2>
<table>
<thead><tr><th>Table</th><th>Rows (est.)</th><th>Data</th><th>Index</th><th>Total</th><th style="width:160px">Share</th></tr></thead>
<tbody>
{% for t in stats.tables %}
<tr>
<td>{{ t.name }}</td>
<td>{{ "{:,}".format(t.rows) }}</td>
<td class="muted">{{ t.data_bytes|bytes }}</td>
<td class="muted">{{ t.index_bytes|bytes }}</td>
<td>{{ t.total_bytes|bytes }}</td>
<td>
<div class="bar"><span style="width:{{ t.pct }}%"></span></div>
<span class="muted">{{ t.pct }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted">Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.</p>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% set show_nav = false %}
{% block title %}login{% endblock %}
{% block body %}
<div class="login">
<h1>Superadmin</h1>
{% if error %}<div class="err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<p><input type="password" name="password" placeholder="Password"
autofocus autocomplete="current-password" style="width:100%"></p>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}not found{% endblock %}
{% block body %}
<h1>User #{{ user_id }} not found</h1>
<p><a href="/users">← back to users</a></p>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}overview{% endblock %}
{% block body %}
<h1>Overview</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_users }}</div><div class="l">Total users</div></div>
<div class="card"><div class="n">{{ stats.paid_active }}</div><div class="l">Paid-active</div></div>
<div class="card"><div class="n">{{ stats.free }}</div><div class="l">Free tier</div></div>
<div class="card"><div class="n">{{ stats.paid }}</div><div class="l">Paid tier</div></div>
<div class="card"><div class="n">{{ stats.enterprise }}</div><div class="l">Enterprise</div></div>
<div class="card"><div class="n">{{ stats.credit_active }}</div><div class="l">On credit</div></div>
<div class="card"><div class="n">{{ stats.signups_7d }}</div><div class="l">Signups · 7d</div></div>
<div class="card"><div class="n">{{ stats.signups_30d }}</div><div class="l">Signups · 30d</div></div>
<div class="card"><div class="n">{{ stats.active_30d }}</div><div class="l">Active · 30d</div></div>
<div class="card"><div class="n">{{ stats.sync_enabled }}</div><div class="l">Cloud-sync on</div></div>
<div class="card"><div class="n">{{ stats.referrals_converted }}/{{ stats.referrals_total }}</div><div class="l">Referrals conv.</div></div>
</div>
<h2>Newest users</h2>
{% include "partials_users_table.html" %}
<p><a href="/users">All users →</a></p>
{% endblock %}

View file

@ -0,0 +1,39 @@
{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #}
{% set rows = rows if rows is defined else recent %}
<table>
<thead>
<tr>
<th>ID</th><th>Email</th><th>Tier</th><th>Paid</th><th>Created</th>
<th>Last login</th><th>Lang</th><th>Sync</th><th>Refs</th><th>Billing</th>
</tr>
</thead>
<tbody>
{% for u in rows %}
<tr>
<td>{{ u.id }}</td>
<td><a href="/users/{{ u.id }}">{{ u.email }}</a></td>
<td>{{ u.tier }}</td>
<td>
{% if u.paid_active %}
<span class="pill good">yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %}</span>
{% else %}
<span class="pill mute">no</span>
{% endif %}
{% if u.trialing %}<span class="pill warn">trial</span>{% endif %}
</td>
<td class="muted">{{ u.created_at|dt }}</td>
<td class="muted">{{ u.last_login_at|dt }}</td>
<td>{{ u.lang }}</td>
<td>{% if u.has_sync %}<span class="pill good">on</span>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{{ u.referrals }}</td>
<td>
{% if u.on_stripe %}<span class="pill">stripe</span>{% endif %}
{% if u.on_polar %}<span class="pill">polar</span>{% endif %}
{% if not u.on_stripe and not u.on_polar %}<span class="muted"></span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="10" class="muted">No users.</td></tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% set u = d.user %}
{% block title %}{{ u.email }}{% endblock %}
{% block body %}
<p class="muted"><a href="/users">← users</a></p>
<h1>{{ u.email }} <span class="muted">#{{ u.id }}</span></h1>
<h2>Account</h2>
<dl class="kv">
<dt>Tier</dt><dd>{{ u.tier }}</dd>
<dt>Paid status</dt><dd>
{% if d.paid.active %}
<span class="pill good">active</span> via {{ d.paid.source }}
{% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %}
{% else %}<span class="pill mute">inactive</span>{% endif %}
</dd>
<dt>Credit until</dt><dd>{{ u.credit_until|dt }}</dd>
<dt>Created</dt><dd>{{ u.created_at|dt }}</dd>
<dt>Last login</dt><dd>{{ u.last_login_at|dt }}</dd>
<dt>Language</dt><dd>{{ u.lang }}</dd>
<dt>Digest opt-in</dt><dd>{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}</dd>
<dt>Referral code</dt><dd>{{ u.referral_code or '—' }}</dd>
<dt>Referred by</dt><dd>{% if d.referred_by %}<a href="/users/{{ d.referred_by.id }}">{{ d.referred_by.email }}</a>{% else %}—{% endif %}</dd>
</dl>
<h2>Billing linkage</h2>
<dl class="kv">
<dt>Stripe customer</dt><dd>{{ u.stripe_customer_id or '—' }}</dd>
<dt>Stripe subscription</dt><dd>{{ u.stripe_subscription_id or '—' }}</dd>
<dt>Stripe trial ends</dt><dd>{{ u.stripe_trial_end_at|dt }}</dd>
<dt>Polar customer</dt><dd>{{ u.polar_customer_id or '—' }}</dd>
<dt>Polar subscription</dt><dd>{{ u.polar_subscription_id or '—' }}</dd>
</dl>
<h2>Cloud sync</h2>
{% if d.sync.enabled %}
<p><span class="pill good">enabled</span> · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }}
<span class="muted">(contents are end-to-end encrypted — not readable here)</span></p>
{% else %}<p class="muted">Not enabled.</p>{% endif %}
<h2>Referrals sent ({{ d.referrals_sent|length }})</h2>
{% if d.referrals_sent %}
<table><thead><tr><th>Referred</th><th>Sent</th><th>Converted</th></tr></thead><tbody>
{% for r in d.referrals_sent %}
<tr><td>{{ r.referred_email }}</td><td class="muted">{{ r.created_at|dt }}</td>
<td>{% if r.converted_at %}<span class="pill good">{{ r.converted_at|dt }}</span>{% else %}<span class="muted">pending</span>{% endif %}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Feedback votes</h2>
<p>👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}</p>
<h2>Digest emails (last 25)</h2>
{% if d.emails %}
<table><thead><tr><th>Kind</th><th>Sent</th><th>Status</th><th>Error</th></tr></thead><tbody>
{% for e in d.emails %}
<tr><td>{{ e.kind }}</td><td class="muted">{{ e.sent_at|dt }}</td>
<td>{% if e.status == 'sent' %}<span class="pill good">sent</span>{% elif e.status == 'error' %}<span class="pill bad">error</span>{% else %}<span class="pill warn">{{ e.status }}</span>{% endif %}</td>
<td class="muted">{{ e.error or '' }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Legal acknowledgements</h2>
{% if d.acks %}
<table><thead><tr><th>Version</th><th>Lang</th><th>Accepted</th></tr></thead><tbody>
{% for a in d.acks %}
<tr><td>v{{ a.version }}</td><td>{{ a.lang }}</td><td class="muted">{{ a.accepted_at|dt }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None recorded.</p>{% endif %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}users{% endblock %}
{% block body %}
<h1>Users <span class="muted">({{ total }})</span></h1>
<form class="search" method="get" action="/users">
<input type="text" name="q" value="{{ q }}" placeholder="filter by email…">
<button type="submit">Search</button>
{% if q %}<a href="/users" style="margin-left:.5rem">clear</a>{% endif %}
</form>
{% include "partials_users_table.html" %}
{% set pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if pages > 1 %}
<p class="muted">
Page {{ page }} / {{ pages }}
{% if page > 1 %}· <a href="/users?page={{ page-1 }}{% if q %}&q={{ q }}{% endif %}">prev</a>{% endif %}
{% if page < pages %}· <a href="/users?page={{ page+1 }}{% if q %}&q={{ q }}{% endif %}">next</a>{% endif %}
</p>
{% endif %}
{% endblock %}