Admin panel: member overview + balance adjustment (proxyAuth, admin-gated)
This commit is contained in:
commit
c8c6e23a28
4 files changed
+305
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "coop.inference.admin-panel",
|
||||
"title": "Admin Panel",
|
||||
"author": "Inference Cooperative",
|
||||
"description": "Admin-only panel: member overview, spend, and balance management.",
|
||||
"tagline": "Inference Cooperative administration",
|
||||
"version": "0.1.0",
|
||||
"healthCheckPath": "/healthz",
|
||||
"httpPort": 8000,
|
||||
"addons": {
|
||||
"localstorage": {},
|
||||
"proxyAuth": {}
|
||||
},
|
||||
"manifestVersion": 2,
|
||||
"website": "https://inference.coop",
|
||||
"contactEmail": "info@inference.coop",
|
||||
"tags": ["admin", "dashboard", "cooperative"]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app/code
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
"""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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
httpx==0.27.2
|
||||
Reference in new issue
Block a user