# Panneau admin interne (`/admin`) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build an internal admin panel at `/admin` for support/debugging — list users, view a user's full subscription history, correct a subscription status manually, all restricted to a single `ADMIN_EMAIL` and logged to an `admin_actions` audit table also viewable at `/admin/journal`. **Architecture:** Dash pages (`register_page`) for reads, one Flask blueprint (`src/admin/routes.py`) for the single write action, both guarded by a shared `is_admin()` check. Mirrors the existing `/compte/*` (Dash) + `/auth/*` (Flask blueprint) split. **Tech Stack:** Dash 3.4, dash-bootstrap-components, Flask, flask-login, flask-wtf (CSRFProtect, already global), sqlite3 (raw, no ORM). **Spec:** `docs/superpowers/specs/2026-07-03-admin-ui-design.md` ## Global Constraints - Valid subscription statuses (from `src/subscriptions/webhooks.py:map_subscription`): `active`, `trial`, `cancelled`, `expired`, `pending`. - `ADMIN_EMAIL` is a single email, compared case-insensitively. No multi-admin support in this lot. - Access guard (`is_admin()` false) always returns/aborts **404**, never a redirect — applies uniformly to anonymous and authenticated-non-admin. - CSRF protection is already global (`CSRFProtect(app)`, `src/auth/setup.py:53`) — no custom CSRF code, just the existing hidden `csrf_token` input pattern. - `dash_table.DataTable` uses `filter_action="native"`, `sort_action="native"`, `page_action="native"`, `page_size=20` everywhere in this feature (no custom/server-side filtering). - Run tests with `uv run pytest` (venv activation via the Bash tool doesn't reliably update `PATH` in this environment). - `sqlite3` connections here are autocommit (`isolation_level=None`, see `src/auth/db.py:56`) — new DB functions must NOT call `conn.commit()`, matching every existing function in `src/auth/db.py` and `src/subscriptions/db.py`. --- ### Task 1: `list_users()` in the auth DB layer **Files:** - Modify: `src/auth/db.py` (add after `get_user_by_id`, around line 148) - Test: `tests/auth/test_db.py` **Interfaces:** - Produces: `list_users(limit: int = 1000) -> list[sqlite3.Row]` — all users, most recently created first, capped at `limit`. - [ ] **Step 1: Write the failing test** Add to `tests/auth/test_db.py`: ```python def test_list_users_orders_by_created_at_desc(users_db_path): from src.auth import db db.init_schema() db.create_user("first@ex.fr", "hash") db.create_user("second@ex.fr", "hash") rows = db.list_users() assert [r["email"] for r in rows] == ["second@ex.fr", "first@ex.fr"] def test_list_users_respects_limit(users_db_path): from src.auth import db db.init_schema() for i in range(3): db.create_user(f"user{i}@ex.fr", "hash") rows = db.list_users(limit=2) assert len(rows) == 2 ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/auth/test_db.py -k list_users -v` Expected: FAIL with `AttributeError: module 'src.auth.db' has no attribute 'list_users'` - [ ] **Step 3: Implement `list_users`** In `src/auth/db.py`, immediately after `get_user_by_id` (line 148): ```python def list_users(limit: int = 1000) -> list[sqlite3.Row]: return ( get_conn() .execute("SELECT * FROM users ORDER BY created_at DESC LIMIT ?", (limit,)) .fetchall() ) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/auth/test_db.py -k list_users -v` Expected: 2 passed - [ ] **Step 5: Commit** ```bash git add src/auth/db.py tests/auth/test_db.py git commit -m "feat(admin): add list_users() to auth DB layer" ``` --- ### Task 2: Subscription DB additions — history, status override, statuses constant **Files:** - Modify: `src/subscriptions/db.py` (add after `get_current`, around line 124, and after `_get_state`, around line 210) - Test: `tests/subscriptions/test_db.py` **Interfaces:** - Produces: `SUBSCRIPTION_STATUSES: tuple[str, ...]` = `("active", "trial", "cancelled", "expired", "pending")` - Produces: `list_by_user(user_id: int) -> list[sqlite3.Row]` — all subscriptions for a user, most recent (`id DESC`) first. - Produces: `set_status(subscription_id: int, status: str) -> None` — overwrites `status` and `updated_at`. - Produces: `get_subscriber_state(user_id: int) -> sqlite3.Row | None` — public wrapper around the existing private `_get_state`. - Consumes: `get_conn()` from `src.auth.db` (already imported), `_now()` (already defined in this module), `create_pending(user_id, customer_handle, plan, prix_ht=None) -> (handle, subscription_id)` (already exists, used by the test). - [ ] **Step 1: Write the failing tests** Add to `tests/subscriptions/test_db.py`: ```python def test_list_by_user_returns_most_recent_first(users_db_path): uid = _make_user() db.init_schema() _handle1, sub_id1 = db.create_pending(uid, "cust-1", "simple") _handle2, sub_id2 = db.create_pending(uid, "cust-1", "soutien") rows = db.list_by_user(uid) assert [r["id"] for r in rows] == [sub_id2, sub_id1] def test_set_status_updates_status(users_db_path): uid = _make_user() db.init_schema() _handle, sub_id = db.create_pending(uid, "cust-1", "simple") db.set_status(sub_id, "active") row = db.get_current(uid) assert row["status"] == "active" def test_get_subscriber_state_returns_row_after_create_pending(users_db_path): uid = _make_user() db.init_schema() db.create_pending(uid, "cust-1", "simple") state = db.get_subscriber_state(uid) assert state is not None assert state["user_id"] == uid def test_get_subscriber_state_returns_none_for_unknown_user(users_db_path): db.init_schema() assert db.get_subscriber_state(999999) is None def test_subscription_statuses_constant(): assert db.SUBSCRIPTION_STATUSES == ( "active", "trial", "cancelled", "expired", "pending", ) ``` (`_make_user` and the `users_db_path` fixture already exist in this file/its `conftest.py` — see `tests/subscriptions/test_db.py:16`.) - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/subscriptions/test_db.py -k "list_by_user or set_status or get_subscriber_state or subscription_statuses" -v` Expected: FAIL with `AttributeError` for each missing symbol - [ ] **Step 3: Implement the additions** In `src/subscriptions/db.py`, immediately after `get_current` (after line 123, before `get_by_handle`): ```python SUBSCRIPTION_STATUSES = ("active", "trial", "cancelled", "expired", "pending") def list_by_user(user_id: int) -> list[sqlite3.Row]: return ( get_conn() .execute( "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY id DESC", (user_id,), ) .fetchall() ) def set_status(subscription_id: int, status: str) -> None: get_conn().execute( "UPDATE subscriptions SET status = ?, updated_at = ? WHERE id = ?", (status, _now(), subscription_id), ) ``` Then, immediately after `_get_state` (after line 210, before `freeze_votes_cursor`): ```python def get_subscriber_state(user_id: int) -> sqlite3.Row | None: return _get_state(user_id) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/subscriptions/test_db.py -k "list_by_user or set_status or get_subscriber_state or subscription_statuses" -v` Expected: 5 passed - [ ] **Step 5: Commit** ```bash git add src/subscriptions/db.py tests/subscriptions/test_db.py git commit -m "feat(admin): add subscription history, status override, and statuses constant" ``` --- ### Task 3: Audit log table + `src/admin/db.py` **Files:** - Modify: `src/migrations.py` (append to `_MIGRATIONS`) - Create: `src/admin/__init__.py` (empty) - Create: `src/admin/db.py` - Create: `tests/admin/__init__.py` (empty) - Create: `tests/admin/conftest.py` - Create: `tests/admin/test_db.py` **Interfaces:** - Produces: table `admin_actions(id, admin_email, action, target_user_id, details, created_at)`. - Produces: `log_action(admin_email: str, action: str, target_user_id: int | None, details: str | None) -> None` - Produces: `list_actions(limit: int = 200) -> list[sqlite3.Row]` — most recent (`id DESC`) first. - Consumes: `get_conn()` from `src.auth.db`, `apply_pending()` from `src.migrations`. - [ ] **Step 1: Add the migration** In `src/migrations.py`, append to `_MIGRATIONS` (after the `0005_...` entry): ```python ( "0006_create_admin_actions", "CREATE TABLE IF NOT EXISTS admin_actions (" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "admin_email TEXT NOT NULL, " "action TEXT NOT NULL, " "target_user_id INTEGER, " "details TEXT, " "created_at TEXT NOT NULL)", ), ``` - [ ] **Step 2: Create the test conftest** Create `tests/admin/__init__.py` (empty file). Create `tests/admin/conftest.py`: ```python import pytest @pytest.fixture def users_db_path(monkeypatch, tmp_path): from src.auth.db import reset_conn_for_tests db_path = tmp_path / "users.test.sqlite" monkeypatch.setenv("USERS_DB_PATH", str(db_path)) reset_conn_for_tests() yield db_path reset_conn_for_tests() ``` - [ ] **Step 3: Write the failing test** Create `tests/admin/test_db.py`: ```python from src.admin import db as admin_db from src.auth.db import init_schema from src.migrations import apply_pending def _setup(): init_schema() apply_pending() def test_log_action_then_list_actions_returns_it(users_db_path): _setup() admin_db.log_action("admin@ex.fr", "subscription_status_change", 42, "active → cancelled") rows = admin_db.list_actions() assert len(rows) == 1 assert rows[0]["admin_email"] == "admin@ex.fr" assert rows[0]["action"] == "subscription_status_change" assert rows[0]["target_user_id"] == 42 assert rows[0]["details"] == "active → cancelled" def test_list_actions_most_recent_first(users_db_path): _setup() admin_db.log_action("admin@ex.fr", "action_one", None, None) admin_db.log_action("admin@ex.fr", "action_two", None, None) rows = admin_db.list_actions() assert [r["action"] for r in rows] == ["action_two", "action_one"] def test_list_actions_respects_limit(users_db_path): _setup() for i in range(3): admin_db.log_action("admin@ex.fr", f"action_{i}", None, None) rows = admin_db.list_actions(limit=2) assert len(rows) == 2 ``` - [ ] **Step 4: Run test to verify it fails** Run: `uv run pytest tests/admin/test_db.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin'` - [ ] **Step 5: Implement `src/admin/db.py`** Create `src/admin/__init__.py` (empty file). Create `src/admin/db.py`: ```python import sqlite3 from datetime import datetime, timezone from src.auth.db import get_conn def _now() -> str: return datetime.now(timezone.utc).isoformat() def log_action( admin_email: str, action: str, target_user_id: int | None, details: str | None ) -> None: get_conn().execute( "INSERT INTO admin_actions (admin_email, action, target_user_id, details, created_at) " "VALUES (?, ?, ?, ?, ?)", (admin_email, action, target_user_id, details, _now()), ) def list_actions(limit: int = 200) -> list[sqlite3.Row]: return ( get_conn() .execute("SELECT * FROM admin_actions ORDER BY id DESC LIMIT ?", (limit,)) .fetchall() ) ``` - [ ] **Step 6: Run tests to verify they pass** Run: `uv run pytest tests/admin/test_db.py -v` Expected: 3 passed - [ ] **Step 7: Commit** ```bash git add src/migrations.py src/admin/__init__.py src/admin/db.py tests/admin/__init__.py tests/admin/conftest.py tests/admin/test_db.py git commit -m "feat(admin): add admin_actions audit table and log/list functions" ``` --- ### Task 4: Access guard — `is_admin()` **Files:** - Create: `src/admin/guard.py` - Create: `tests/admin/test_guard.py` **Interfaces:** - Produces: `is_admin() -> bool` — `True` only if `ADMIN_EMAIL` is set, `current_user.is_authenticated`, and `current_user.email` matches case-insensitively. - [ ] **Step 1: Write the failing tests** Create `tests/admin/test_guard.py`: ```python from unittest.mock import patch from src.admin import guard def _fake_user(email: str, authenticated: bool = True): user = type("U", (), {})() user.is_authenticated = authenticated user.email = email return user def test_is_admin_true_for_matching_email(monkeypatch): monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): assert guard.is_admin() is True def test_is_admin_true_case_insensitive(monkeypatch): monkeypatch.setenv("ADMIN_EMAIL", "Admin@Ex.fr") with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): assert guard.is_admin() is True def test_is_admin_false_for_different_email(monkeypatch): monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") with patch("src.admin.guard.current_user", _fake_user("someone@ex.fr")): assert guard.is_admin() is False def test_is_admin_false_for_anonymous(monkeypatch): monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") with patch( "src.admin.guard.current_user", _fake_user("admin@ex.fr", authenticated=False) ): assert guard.is_admin() is False def test_is_admin_false_when_admin_email_unset(monkeypatch): monkeypatch.delenv("ADMIN_EMAIL", raising=False) with patch("src.admin.guard.current_user", _fake_user("admin@ex.fr")): assert guard.is_admin() is False ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/admin/test_guard.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin.guard'` - [ ] **Step 3: Implement `src/admin/guard.py`** ```python import os from flask_login import current_user def is_admin() -> bool: admin_email = os.getenv("ADMIN_EMAIL") return bool( admin_email and current_user.is_authenticated and current_user.email.lower() == admin_email.lower() ) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/admin/test_guard.py -v` Expected: 5 passed - [ ] **Step 5: Commit** ```bash git add src/admin/guard.py tests/admin/test_guard.py git commit -m "feat(admin): add is_admin() access guard" ``` --- ### Task 5: Mutation route — `POST /admin/actions/subscription-status` **Files:** - Create: `src/admin/routes.py` - Modify: `src/auth/setup.py` (register blueprint) - Modify: `.template.env` (add `ADMIN_EMAIL`) - Modify: `tests/admin/conftest.py` (add Flask app/client fixtures) - Create: `tests/admin/test_routes.py` **Interfaces:** - Produces: Flask blueprint `admin_bp` (`url_prefix="/admin/actions"`), route `POST /subscription-status`. - Consumes: `is_admin()` (Task 4), `SUBSCRIPTION_STATUSES`, `get_current`, `set_status` (Task 2), `log_action` (Task 3). - [ ] **Step 1: Extend the test conftest** In `tests/admin/conftest.py`, add (after the existing `users_db_path` fixture): ```python @pytest.fixture def admin_app(users_db_path, monkeypatch): from flask import Flask from src.auth.setup import init_auth from src.subscriptions.setup import init_subscriptions monkeypatch.setenv("SECRET_KEY", "test-secret-key") monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") flask_app = Flask(__name__) flask_app.config["WTF_CSRF_ENABLED"] = False init_auth(flask_app) init_subscriptions(flask_app) return flask_app @pytest.fixture def admin_client(admin_app): return admin_app.test_client() @pytest.fixture def logged_in_admin_client(admin_app, monkeypatch): from src.auth import db as auth_db monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") uid = auth_db.create_user("admin@ex.fr", "hash") auth_db.set_email_verified(uid) client = admin_app.test_client() with client.session_transaction() as sess: sess["_user_id"] = str(uid) sess["_fresh"] = True return client, uid ``` This mirrors `tests/subscriptions/conftest.py:62-106` (`sub_app`/`logged_in_client`). - [ ] **Step 2: Write the failing tests** Create `tests/admin/test_routes.py`: ```python from src.auth import db as auth_db from src.subscriptions import db as sub_db def _make_target_with_subscription(): uid = auth_db.create_user("target@ex.fr", "hash") _handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple") return uid, sub_id def test_subscription_status_requires_admin(admin_client): resp = admin_client.post( "/admin/actions/subscription-status", data={"user_id": "1", "subscription_id": "1", "status": "active"}, ) assert resp.status_code == 404 def test_subscription_status_rejects_invalid_status(logged_in_admin_client): client, _admin_uid = logged_in_admin_client uid, sub_id = _make_target_with_subscription() resp = client.post( "/admin/actions/subscription-status", data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "bogus"}, ) assert resp.status_code == 302 assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" assert sub_db.get_current(uid)["status"] == "pending" def test_subscription_status_rejects_mismatched_subscription(logged_in_admin_client): client, _admin_uid = logged_in_admin_client uid, _sub_id = _make_target_with_subscription() other_uid, other_sub_id = _make_target_with_subscription() resp = client.post( "/admin/actions/subscription-status", data={ "user_id": str(uid), "subscription_id": str(other_sub_id), "status": "active", }, ) assert resp.status_code == 302 assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" assert sub_db.get_current(other_uid)["status"] == "pending" def test_subscription_status_success_updates_and_logs(logged_in_admin_client): from src.admin.db import list_actions client, _admin_uid = logged_in_admin_client uid, sub_id = _make_target_with_subscription() resp = client.post( "/admin/actions/subscription-status", data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "active"}, ) assert resp.status_code == 302 assert resp.headers["Location"] == f"/admin/user/{uid}?status_changed=1" assert sub_db.get_current(uid)["status"] == "active" actions = list_actions() assert len(actions) == 1 assert actions[0]["action"] == "subscription_status_change" assert actions[0]["target_user_id"] == uid assert actions[0]["details"] == "pending → active" assert actions[0]["admin_email"] == "admin@ex.fr" ``` - [ ] **Step 3: Run tests to verify they fail** Run: `uv run pytest tests/admin/test_routes.py -v` Expected: FAIL — `admin_client`/`logged_in_admin_client` fixtures raise `ModuleNotFoundError: No module named 'src.admin.routes'` (via `init_auth` once it tries to import it in Step 5) or `404`/`ImportError` depending on order; before Step 5, `test_subscription_status_requires_admin` will fail with a connection/404-mismatch since the blueprint doesn't exist yet (real 404 from Flask's default routing, not from `is_admin()` — acceptable coincidence, but the other three tests will fail). - [ ] **Step 4: Implement `src/admin/routes.py`** ```python from flask import Blueprint, abort, redirect, request from flask_login import current_user from src.admin.db import log_action from src.admin.guard import is_admin from src.subscriptions.db import SUBSCRIPTION_STATUSES, get_current, set_status admin_bp = Blueprint("admin", __name__, url_prefix="/admin/actions") @admin_bp.before_request def _require_admin(): if not is_admin(): abort(404) @admin_bp.route("/subscription-status", methods=["POST"]) def subscription_status(): user_id = request.form.get("user_id", type=int) subscription_id = request.form.get("subscription_id", type=int) status = request.form.get("status", "") if user_id is None or subscription_id is None: abort(400) current = get_current(user_id) if ( status not in SUBSCRIPTION_STATUSES or current is None or current["id"] != subscription_id ): return redirect(f"/admin/user/{user_id}?error=invalid_status") old_status = current["status"] set_status(subscription_id, status) log_action( current_user.email, "subscription_status_change", user_id, f"{old_status} → {status}", ) return redirect(f"/admin/user/{user_id}?status_changed=1") ``` - [ ] **Step 5: Register the blueprint** In `src/auth/setup.py`, immediately after `app.register_blueprint(auth_bp)` (currently the line right after the `from src.auth.routes import auth_bp` block): ```python from src.auth.routes import auth_bp app.register_blueprint(auth_bp) from src.admin.routes import admin_bp app.register_blueprint(admin_bp) ``` - [ ] **Step 6: Add `ADMIN_EMAIL` to `.template.env`** In `.template.env`, add a new section after the "Comptes utilisateurs" block (after `APP_BASE_URL=http://localhost:8050`): ``` # Panneau admin interne (accès à /admin, protégé par cette adresse) ADMIN_EMAIL= ``` - [ ] **Step 7: Run tests to verify they pass** Run: `uv run pytest tests/admin/test_routes.py -v` Expected: 4 passed - [ ] **Step 8: Commit** ```bash git add src/admin/routes.py src/auth/setup.py .template.env tests/admin/conftest.py tests/admin/test_routes.py git commit -m "feat(admin): add subscription-status mutation route" ``` --- ### Task 6: Shared page chrome + `/admin` list page **Files:** - Create: `src/pages/admin/__init__.py` (empty) - Create: `src/pages/admin/_shell.py` - Create: `src/pages/admin/liste.py` **Interfaces:** - Produces: `not_admin() -> Component`, `admin_nav(active: str) -> Component` (in `_shell.py`), reused by Tasks 7 and 8. - Consumes: `is_admin()` (Task 4), `list_users()` (Task 1), `get_current()` (existing). - [ ] **Step 1: Create the package and shared shell** Create `src/pages/admin/__init__.py` (empty file). Create `src/pages/admin/_shell.py`: ```python import dash_bootstrap_components as dbc from dash import html def not_admin(): return html.Div( html.H2("404", id="admin-404-heading"), className="py-5 text-center" ) def admin_nav(active: str): return dbc.Nav( [ dbc.NavLink("Utilisateurs", href="/admin", active=(active == "liste")), dbc.NavLink( "Journal", href="/admin/journal", active=(active == "journal") ), ], pills=True, class_name="mb-4", ) ``` (`html.H2` here, not `H1` — the navbar already renders a global `