diff --git a/.env.example b/.env.example index 83f4f24..f2a593b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,13 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation) # --- App --- CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty CASSANDRA_PORT=8000 + +# --- Superadmin console (internal-only, separate `admin` container) --- +# Password for the read-only operator dashboard at 127.0.0.1:8091 (reach it +# via `ssh -L 8091:localhost:8091 `). Empty = console refuses every +# login (closed by default). Set a strong value in prod. +ADMIN_CONSOLE_PASSWORD= +ADMIN_CONSOLE_SESSION_SECRET= # cookie signing key; falls back to CASSANDRA_SESSION_SECRET/TOKEN if empty CASSANDRA_BASE_CURRENCY=GBP CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs diff --git a/Dockerfile b/Dockerfile index 09c6443..94b8042 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,12 @@ RUN apt-get update \ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ -# Default command is the web app; scheduler container overrides via `command:`. +# Default command is the web app; the scheduler and admin-console containers +# override via `command:` (see docker-compose.yml). EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] @@ -57,6 +59,7 @@ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY pyproject.toml requirements.lock ./ COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ # tests/ is excluded by .dockerignore (prod-correct: never bake tests into diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..bd1685f --- /dev/null +++ b/admin/README.md @@ -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 + # 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 +``` diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..5f9b1c7 --- /dev/null +++ b/admin/__init__.py @@ -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. +""" diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..a85067b --- /dev/null +++ b/admin/auth.py @@ -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"}, + ) diff --git a/admin/main.py b/admin/main.py new file mode 100644 index 0000000..324dd72 --- /dev/null +++ b/admin/main.py @@ -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}) diff --git a/admin/queries.py b/admin/queries.py new file mode 100644 index 0000000..d2c9fec --- /dev/null +++ b/admin/queries.py @@ -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} diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..003e163 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,80 @@ + + + + + + + {% block title %}superadmin{% endblock %} · read.markets + + + + {% if show_nav|default(true) %} +
+ read.markets · superadmin + + + Log out +
+ {% endif %} +
{% block body %}{% endblock %}
+ + diff --git a/admin/templates/db.html b/admin/templates/db.html new file mode 100644 index 0000000..7ccde4c --- /dev/null +++ b/admin/templates/db.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}database{% endblock %} +{% block body %} +

Database usage

+
+
{{ stats.total_bytes|bytes }}
Total size
+
{{ "{:,}".format(stats.total_rows) }}
Total rows (est.)
+
{{ stats.tables|length }}
Tables
+
+ +

Per table

+ + + + {% for t in stats.tables %} + + + + + + + + + {% endfor %} + +
TableRows (est.)DataIndexTotalShare
{{ t.name }}{{ "{:,}".format(t.rows) }}{{ t.data_bytes|bytes }}{{ t.index_bytes|bytes }}{{ t.total_bytes|bytes }} +
+ {{ t.pct }}% +
+

Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.

+{% endblock %} diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..aff4c50 --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% set show_nav = false %} +{% block title %}login{% endblock %} +{% block body %} + +{% endblock %} diff --git a/admin/templates/not_found.html b/admin/templates/not_found.html new file mode 100644 index 0000000..6e41006 --- /dev/null +++ b/admin/templates/not_found.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}not found{% endblock %} +{% block body %} +

User #{{ user_id }} not found

+

← back to users

+{% endblock %} diff --git a/admin/templates/overview.html b/admin/templates/overview.html new file mode 100644 index 0000000..06f3a0a --- /dev/null +++ b/admin/templates/overview.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}overview{% endblock %} +{% block body %} +

Overview

+
+
{{ stats.total_users }}
Total users
+
{{ stats.paid_active }}
Paid-active
+
{{ stats.free }}
Free tier
+
{{ stats.paid }}
Paid tier
+
{{ stats.enterprise }}
Enterprise
+
{{ stats.credit_active }}
On credit
+
{{ stats.signups_7d }}
Signups · 7d
+
{{ stats.signups_30d }}
Signups · 30d
+
{{ stats.active_30d }}
Active · 30d
+
{{ stats.sync_enabled }}
Cloud-sync on
+
{{ stats.referrals_converted }}/{{ stats.referrals_total }}
Referrals conv.
+
+ +

Newest users

+{% include "partials_users_table.html" %} +

All users →

+{% endblock %} diff --git a/admin/templates/partials_users_table.html b/admin/templates/partials_users_table.html new file mode 100644 index 0000000..5525464 --- /dev/null +++ b/admin/templates/partials_users_table.html @@ -0,0 +1,39 @@ +{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #} +{% set rows = rows if rows is defined else recent %} + + + + + + + + + {% for u in rows %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDEmailTierPaidCreatedLast loginLangSyncRefsBilling
{{ u.id }}{{ u.email }}{{ u.tier }} + {% if u.paid_active %} + yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %} + {% else %} + no + {% endif %} + {% if u.trialing %}trial{% endif %} + {{ u.created_at|dt }}{{ u.last_login_at|dt }}{{ u.lang }}{% if u.has_sync %}on{% else %}{% endif %}{{ u.referrals }} + {% if u.on_stripe %}stripe{% endif %} + {% if u.on_polar %}polar{% endif %} + {% if not u.on_stripe and not u.on_polar %}{% endif %} +
No users.
diff --git a/admin/templates/user_detail.html b/admin/templates/user_detail.html new file mode 100644 index 0000000..715c56f --- /dev/null +++ b/admin/templates/user_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% set u = d.user %} +{% block title %}{{ u.email }}{% endblock %} +{% block body %} +

← users

+

{{ u.email }} #{{ u.id }}

+ +

Account

+
+
Tier
{{ u.tier }}
+
Paid status
+ {% if d.paid.active %} + active via {{ d.paid.source }} + {% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %} + {% else %}inactive{% endif %} +
+
Credit until
{{ u.credit_until|dt }}
+
Created
{{ u.created_at|dt }}
+
Last login
{{ u.last_login_at|dt }}
+
Language
{{ u.lang }}
+
Digest opt-in
{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}
+
Referral code
{{ u.referral_code or '—' }}
+
Referred by
{% if d.referred_by %}{{ d.referred_by.email }}{% else %}—{% endif %}
+
+ +

Billing linkage

+
+
Stripe customer
{{ u.stripe_customer_id or '—' }}
+
Stripe subscription
{{ u.stripe_subscription_id or '—' }}
+
Stripe trial ends
{{ u.stripe_trial_end_at|dt }}
+
Polar customer
{{ u.polar_customer_id or '—' }}
+
Polar subscription
{{ u.polar_subscription_id or '—' }}
+
+ +

Cloud sync

+{% if d.sync.enabled %} +

enabled · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }} + (contents are end-to-end encrypted — not readable here)

+{% else %}

Not enabled.

{% endif %} + +

Referrals sent ({{ d.referrals_sent|length }})

+{% if d.referrals_sent %} + + {% for r in d.referrals_sent %} + + + {% endfor %} +
ReferredSentConverted
{{ r.referred_email }}{{ r.created_at|dt }}{% if r.converted_at %}{{ r.converted_at|dt }}{% else %}pending{% endif %}
+{% else %}

None.

{% endif %} + +

Feedback votes

+

👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}

+ +

Digest emails (last 25)

+{% if d.emails %} + + {% for e in d.emails %} + + + + {% endfor %} +
KindSentStatusError
{{ e.kind }}{{ e.sent_at|dt }}{% if e.status == 'sent' %}sent{% elif e.status == 'error' %}error{% else %}{{ e.status }}{% endif %}{{ e.error or '' }}
+{% else %}

None.

{% endif %} + +

Legal acknowledgements

+{% if d.acks %} + + {% for a in d.acks %} + + {% endfor %} +
VersionLangAccepted
v{{ a.version }}{{ a.lang }}{{ a.accepted_at|dt }}
+{% else %}

None recorded.

{% endif %} +{% endblock %} diff --git a/admin/templates/users.html b/admin/templates/users.html new file mode 100644 index 0000000..72bde22 --- /dev/null +++ b/admin/templates/users.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}users{% endblock %} +{% block body %} +

Users ({{ total }})

+ +{% include "partials_users_table.html" %} + +{% set pages = (total // per_page) + (1 if total % per_page else 0) %} +{% if pages > 1 %} +

+ Page {{ page }} / {{ pages }} + {% if page > 1 %}· prev{% endif %} + {% if page < pages %}· next{% endif %} +

+{% endif %} +{% endblock %} diff --git a/app/config.py b/app/config.py index 975f165..07052d5 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,17 @@ class Settings(BaseSettings): # created. Phase A leaves this open so the operator can self-onboard. CASSANDRA_SIGNUP_ENABLED: bool = True + # Superadmin console (independent container, internal-only — bound to + # 127.0.0.1 on the VPS, reached via SSH tunnel). Read-only operator + # dashboard: user list, per-user history/payment status, DB usage stats. + # A single shared password gates it; empty ADMIN_CONSOLE_PASSWORD makes + # every login attempt fail (the console is closed rather than open by + # default, so a fresh deploy can't accidentally expose it). + ADMIN_CONSOLE_PASSWORD: str = "" + # Signing secret for the console's session cookie. Falls back to + # CASSANDRA_SESSION_SECRET / CASSANDRA_TOKEN like the main app's cookie. + ADMIN_CONSOLE_SESSION_SECRET: str = "" + # SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes # are written to stdout instead of sent — convenient for local dev. SMTP_SERVER: str = "" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0a18178..ac3f6ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -44,6 +44,14 @@ services: DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} REDIS_URL: redis://readmarkets-redis-1:6379/0 + admin: + # Same DNS-collision reasoning as app/scheduler: use the project-prefixed + # container name for the DB. The console stays OFF the intranet network — + # it is internal-only (127.0.0.1:8091 host port from the base file), so it + # never needs to be reachable by NPM. + environment: + DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} + networks: intranet: external: true diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 219930c..736b23f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -33,6 +33,7 @@ services: # on the next `run` without rebuilding the image. volumes: - ./app:/app/app + - ./admin:/app/admin - ./tests:/app/tests - ./alembic:/app/alembic - ./alembic.ini:/app/alembic.ini:ro diff --git a/docker-compose.yml b/docker-compose.yml index 8a7e03f..a25b80d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,33 @@ services: redis: condition: service_healthy + # Superadmin console — independent read-only operator dashboard. Same + # image (reuses app.db/app.models) but runs admin.main:app instead of the + # public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is + # reached over an SSH tunnel, never exposed publicly (no intranet/NPM). + admin: + build: . + restart: unless-stopped + command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] + env_file: .env + environment: + DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra} + volumes: + - ./config:/app/config:ro + - ./app:/app/app + - ./admin:/app/admin + ports: + # Host-loopback only — access via `ssh -L 8091:localhost:8091 `. + - "127.0.0.1:8091:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"] + interval: 30s + timeout: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + backup: image: mariadb:11 restart: unless-stopped diff --git a/tests/test_admin_console.py b/tests/test_admin_console.py new file mode 100644 index 0000000..f851fc5 --- /dev/null +++ b/tests/test_admin_console.py @@ -0,0 +1,264 @@ +"""Superadmin console — auth gate, read-only queries, and page wiring. + +Runs against an in-memory-ish sqlite file (same pattern as the other API +tests): seed a handful of users + related rows, rebind app.db to the test +engine, then drive both the query layer directly and the FastAPI app via +TestClient. +""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + + +def _seed(tmp_path): + """Create schema + fixture rows; rebind app.db. Returns the factory.""" + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base, utcnow + from app.models import ( + EmailSend, PortfolioSync, Referral, StrategicLogFeedback, + User, UserAcknowledgement, + ) + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + now = utcnow() + + async def _go(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + # 1: free, referrer + s.add(User(id=1, email="alice@x", tier="free", created_at=now, + last_login_at=now, referral_code="ALICE")) + # 2: paid subscriber via Stripe + s.add(User(id=2, email="bob@x", tier="paid", created_at=now, + last_login_at=now, stripe_customer_id="cus_1", + stripe_subscription_id="sub_1")) + # 3: free but credit-active, referred by alice, has sync/history + s.add(User(id=3, email="carol@x", tier="free", created_at=now, + credit_until=now + timedelta(days=30), + referred_by_user_id=1)) + await s.flush() + s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", + outer_nonce=b"y", version=1, + created_at=now, updated_at=now)) + s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) + s.add(UserAcknowledgement(user_id=3, version=2, lang="en", + accepted_at=now)) + s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up", + created_at=now, updated_at=now)) + s.add(Referral(referrer_user_id=1, referred_user_id=3, + created_at=now, converted_at=now)) + await s.commit() + + asyncio.run(_go()) + return factory + + +# --- auth ------------------------------------------------------------------ + +def test_check_password(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret") + get_settings.cache_clear() + assert auth.check_password("s3cret") is True + assert auth.check_password("wrong") is False + get_settings.cache_clear() + + +def test_check_password_empty_denies(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "") + get_settings.cache_clear() + # Empty password must reject everything — console closed by default. + assert auth.check_password("") is False + assert auth.check_password("anything") is False + get_settings.cache_clear() + + +def test_session_round_trip(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret") + get_settings.cache_clear() + token = auth.sign_session() + assert auth.verify_session(token) is True + assert auth.verify_session("garbage") is False + get_settings.cache_clear() + + +# --- queries --------------------------------------------------------------- + +def test_overview_stats(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.overview_stats(s) + + stats = asyncio.run(_go()) + assert stats["total_users"] == 3 + assert stats["free"] == 2 + assert stats["paid"] == 1 + # bob (tier paid) + carol (credit) both count as paid-active. + assert stats["paid_active"] == 2 + assert stats["credit_active"] == 1 + assert stats["sync_enabled"] == 1 + assert stats["referrals_total"] == 1 + assert stats["referrals_converted"] == 1 + + +def test_list_users(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.list_users(s) + + rows, total = asyncio.run(_go()) + assert total == 3 + by_id = {r["id"]: r for r in rows} + assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier" + assert by_id[2]["on_stripe"] is True + assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit" + assert by_id[3]["has_sync"] is True + assert by_id[1]["referrals"] == 1 + assert by_id[1]["paid_active"] is False + + +def test_list_users_search(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.list_users(s, q="bob") + + rows, total = asyncio.run(_go()) + assert total == 1 and rows[0]["email"] == "bob@x" + + +def test_user_detail(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.user_detail(s, 3) + + d = asyncio.run(_go()) + assert d is not None + assert d["user"].email == "carol@x" + assert d["paid"].active and d["paid"].source == "credit" + assert d["referred_by"]["email"] == "alice@x" + assert d["sync"]["enabled"] is True + assert d["feedback"].get("up") == 1 + assert len(d["emails"]) == 1 + assert len(d["acks"]) == 1 + + +def test_user_detail_missing(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.user_detail(s, 999) + + assert asyncio.run(_go()) is None + + +def test_db_stats_sqlite_fallback(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.db_stats(s) + + stats = asyncio.run(_go()) + names = {t["name"] for t in stats["tables"]} + assert "users" in names + users_row = next(t for t in stats["tables"] if t["name"] == "users") + assert users_row["rows"] == 3 + + +# --- app wiring ------------------------------------------------------------ + +def _client(tmp_path, monkeypatch): + from fastapi.testclient import TestClient + from app.config import get_settings + + _seed(tmp_path) + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein") + get_settings.cache_clear() + from admin.main import app + return TestClient(app, follow_redirects=False) + + +def test_pages_require_login(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + for path in ("/", "/users", "/users/1", "/db"): + r = client.get(path) + assert r.status_code == 303, path + assert r.headers["location"] == "/login" + get_settings_clear() + + +def test_login_flow(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + + # Wrong password → 401, no cookie. + r = client.post("/login", data={"password": "nope"}) + assert r.status_code == 401 + assert "admin_console_session" not in r.cookies + + # Right password → 303 to / with a session cookie. + r = client.post("/login", data={"password": "letmein"}) + assert r.status_code == 303 + assert r.headers["location"] == "/" + cookie = r.cookies.get("admin_console_session") + assert cookie + + # Authenticated pages now load. + r = client.get("/", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "Overview" in r.text + + r = client.get("/users", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "carol@x" in r.text + + r = client.get("/users/3", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "carol@x" in r.text and "alice@x" in r.text + + r = client.get("/db", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "users" in r.text + get_settings_clear() + + +def test_healthz(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + r = client.get("/healthz") + assert r.status_code == 200 and r.text == "ok" + get_settings_clear() + + +def get_settings_clear(): + from app.config import get_settings + get_settings.cache_clear()