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:
parent
8946dee2e0
commit
411094d7b8
20 changed files with 1143 additions and 1 deletions
73
admin/templates/user_detail.html
Normal file
73
admin/templates/user_detail.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue