Files
admin-panel/app/main.py
T

271 lines
9.6 KiB
Python

"""Admin Panel — co-op-wide member overview, spend, and balance management.
Security model:
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
the authenticated user's username via X-Remote-User.
- Access is further restricted to a small ADMIN_USERNAMES allowlist (checked
against the injected username), so only designated admins see this panel.
- This app calls the member portal's /admin/overview and /admin/set-balance
endpoints, authenticated with the portal's WEBHOOK_TOKEN (ADMIN_TOKEN).
Environment (Cloudron env vars):
PORTAL_BASE — base URL of the member portal
ADMIN_TOKEN — the portal's admin/webhook token (for /admin/* endpoints)
ADMIN_USERNAMES — comma-separated list of allowed Cloudron usernames
"""
import os
import logging
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("admin-panel")
PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
ADMIN_USERNAMES = {
u.strip().lower()
for u in os.environ.get("ADMIN_USERNAMES", "").split(",")
if u.strip()
}
app = FastAPI()
def get_identity(request: Request) -> str:
for header in (
"x-remote-user",
"x-forwarded-user",
"x-auth-request-user",
"x-auth-request-email",
"x-forwarded-email",
):
val = request.headers.get(header)
if val:
return val.strip()
return ""
def is_admin(request: Request) -> bool:
identity = get_identity(request)
if not identity:
return False
return identity.lower() in ADMIN_USERNAMES
async def portal_get(path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}")
if r.status_code != 200:
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json()
async def portal_post(path: str, payload: dict) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", json=payload)
if r.status_code not in (200, 201):
try:
detail = r.json().get("detail", r.text)
except Exception:
detail = r.text
raise HTTPException(502, f"Portal error {r.status_code}: {detail}")
return r.json()
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/")
async def index(request: Request):
if not is_admin(request):
return HTMLResponse(
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
status_code=403,
)
return HTMLResponse(render_page())
@app.get("/api/overview")
async def api_overview(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
return await portal_get(f"/admin/overview/{ADMIN_TOKEN}")
@app.post("/api/set-balance")
async def api_set_balance(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
body = await request.json()
email = (body.get("email") or "").strip().lower()
if not email:
return JSONResponse({"error": "Missing email"}, status_code=400)
payload = {"email": email}
if "add" in body:
payload["add"] = float(body["add"])
elif "balance" in body:
payload["balance"] = float(body["balance"])
else:
return JSONResponse({"error": "Provide 'balance' or 'add'"}, status_code=400)
try:
return await portal_post(f"/admin/set-balance/{ADMIN_TOKEN}", payload)
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def render_page() -> str:
return PAGE_HTML
PAGE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inference Cooperative · Admin</title>
<style>
:root {
--cream: #faf8f5; --ink: #2d3327; --muted: #6b7a62;
--green-deep: #5b8c5a; --green-mid: #6ba86b; --border: #e4e0d8; --danger: #b3543a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Figtree', -apple-system, 'Segoe UI', system-ui, sans-serif; background: var(--cream); color: var(--ink); line-height: 1.5; }
.wrap { max-width: 980px; margin: 0 auto; padding: 32px 24px 48px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
.brand h1 { font-size: 20px; font-weight: 700; }
.brand .sub { color: var(--muted); font-size: 13px; }
.totals { display: flex; gap: 20px; margin-bottom: 24px; flex-wrap: wrap; }
.tot { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; min-width: 140px; }
.tot .num { font-size: 24px; font-weight: 700; color: var(--green-deep); }
.tot .lbl { font-size: 12px; color: var(--muted); }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid #f0ede6; font-size: 13px; }
th { background: #f6f4ee; color: var(--muted); font-weight: 600; font-size: 12px; }
tr:last-child td { border-bottom: none; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.active { background: #eaf3ea; color: #2f5c30; }
.pill.inactive { background: #f6e9e5; color: var(--danger); }
.num { font-variant-numeric: tabular-nums; }
.flash { padding: 12px 16px; border-radius: 8px; margin: 16px 0; font-size: 14px; display: none; }
.flash.show { display: block; }
.flash.ok { background: #eaf3ea; color: #2f5c30; }
.flash.err { background: #f6e9e5; color: var(--danger); }
.adjust { display: inline-flex; gap: 6px; }
.adjust input { width: 64px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
.adjust button { cursor: pointer; border: none; border-radius: 6px; background: var(--green-deep); color: #fff; font-size: 12px; font-weight: 600; padding: 4px 10px; }
.adjust button:hover { background: #4e7a4d; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<h1>Inference Cooperative</h1>
<div class="sub">Admin panel</div>
</div>
</header>
<div class="totals">
<div class="tot"><div class="num" id="tot-members">—</div><div class="lbl">members</div></div>
<div class="tot"><div class="num" id="tot-active">—</div><div class="lbl">active</div></div>
<div class="tot"><div class="num" id="tot-spend">—</div><div class="lbl">total spend</div></div>
</div>
<div class="flash" id="flash"></div>
<table>
<thead>
<tr>
<th>Member</th><th>Status</th><th>Balance</th><th>Spend</th><th>Remaining</th><th>Reset</th><th>Adjust</th>
</tr>
</thead>
<tbody id="rows"><tr><td colspan="7" style="color:var(--muted)">Loading…</td></tr></tbody>
</table>
</div>
<script>
const $ = (id) => document.getElementById(id);
function flash(msg, ok) {
const f = $('flash');
f.textContent = msg;
f.className = 'flash show ' + (ok ? 'ok' : 'err');
setTimeout(() => { f.className = 'flash'; }, 6000);
}
function fmt(n) { return '$' + (Number(n) || 0).toFixed(2); }
async function load() {
try {
const r = await fetch('/api/overview');
if (r.status === 403) { document.body.innerHTML = '<h1>Forbidden</h1>'; return; }
const d = await r.json();
$('tot-members').textContent = d.totals.members;
$('tot-active').textContent = d.totals.active;
$('tot-spend').textContent = fmt(d.totals.total_spend);
render(d.members);
} catch (e) {
flash('Could not load: ' + e.message, false);
}
}
function render(members) {
$('rows').innerHTML = members.map(m => {
const reset = m.reset_at ? m.reset_at.slice(0,10) : '—';
const pill = m.active ? 'active' : 'inactive';
return '<tr>' +
'<td>' + esc(m.email) + '</td>' +
'<td><span class="pill ' + pill + '">' + pill + '</span></td>' +
'<td class="num">' + fmt(m.balance) + '</td>' +
'<td class="num">' + fmt(m.spend) + '</td>' +
'<td class="num">' + fmt(m.remaining) + '</td>' +
'<td>' + esc(reset) + '</td>' +
'<td><div class="adjust">' +
'<input id="add-' + i(m.email) + '" placeholder="+$" />' +
'<button onclick="addBalance(\'' + esc(m.email) + '\')">Add</button>' +
'</div></td>' +
'</tr>';
}).join('');
}
function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function addBalance(email) {
const el = $('add-' + i(email));
const val = parseFloat(el.value);
if (!val || isNaN(val)) { flash('Enter a number', false); return; }
try {
const r = await fetch('/api/set-balance', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ email, add: val })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Added ' + fmt(val) + ' to ' + email, true); el.value = ''; load(); }
} catch (e) { flash(e.message, false); }
}
load();
</script>
</body>
</html>"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)