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

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 %}