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>