No manifest change yet — proxyAuth stays active, so get_identity() still works via header. OIDC routes only activate when CLOUDRON_OIDC_* env vars are present. Validates id_token signature (JWKS), iss/aud/nonce, exp/nbf.
354 lines
13 KiB
Python
354 lines
13 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
|
|
|
|
from app import oidc
|
|
|
|
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:
|
|
"""Return the authenticated user's identity (Cloudron username).
|
|
|
|
Priority: a validated OIDC session cookie (when the oidc addon is active),
|
|
then the proxyAuth header (legacy, and the fallback during the transition).
|
|
"""
|
|
# OIDC session cookie (only when the addon is configured).
|
|
if oidc.is_oidc_configured():
|
|
token = request.cookies.get(oidc.SESSION_COOKIE)
|
|
if token:
|
|
identity = oidc.read_session(token)
|
|
if identity:
|
|
return identity
|
|
|
|
# Legacy proxyAuth header fallback.
|
|
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}", headers={"X-Admin-Token": ADMIN_TOKEN})
|
|
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, headers={"X-Admin-Token": ADMIN_TOKEN})
|
|
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):
|
|
identity = get_identity(request)
|
|
# Unauthenticated + OIDC configured → send to the OIDC login.
|
|
if not identity:
|
|
if oidc.is_oidc_configured():
|
|
return RedirectResponse("/auth/openid/login", status_code=302)
|
|
return HTMLResponse(
|
|
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
|
|
status_code=403,
|
|
)
|
|
# Authenticated but not an admin → forbid.
|
|
if identity.lower() not in ADMIN_USERNAMES:
|
|
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("/admin/overview")
|
|
|
|
|
|
@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("/admin/set-balance", payload)
|
|
except HTTPException as e:
|
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OIDC routes — authorization-code flow against Cloudron's OIDC provider.
|
|
# Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present).
|
|
# During the transition the legacy proxyAuth header still works as a fallback.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.get("/auth/openid/login")
|
|
async def oidc_login(request: Request):
|
|
if not oidc.is_oidc_configured():
|
|
raise HTTPException(404, "OIDC not configured")
|
|
login_url, state, nonce = oidc.build_login_url(request.headers.get("host", ""))
|
|
resp = RedirectResponse(login_url, status_code=302)
|
|
# Short-lived, HttpOnly, SameSite=Lax cookies to carry state/nonce.
|
|
resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax")
|
|
resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax")
|
|
return resp
|
|
|
|
|
|
@app.get("/auth/openid/callback")
|
|
async def oidc_callback(request: Request):
|
|
if not oidc.is_oidc_configured():
|
|
raise HTTPException(404, "OIDC not configured")
|
|
code = request.query_params.get("code")
|
|
state = request.query_params.get("state")
|
|
expected_state = request.cookies.get("oidc_state")
|
|
nonce = request.cookies.get("oidc_nonce")
|
|
|
|
if not code or not state or not expected_state or not nonce:
|
|
return HTMLResponse("<h1>Login failed</h1><p>Incomplete OIDC callback.</p>", status_code=400)
|
|
if state != expected_state:
|
|
return HTMLResponse("<h1>Login failed</h1><p>State mismatch (possible CSRF).</p>", status_code=400)
|
|
|
|
try:
|
|
identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce)
|
|
except ValueError as e:
|
|
logger.warning("OIDC login failed: %s", e)
|
|
return HTMLResponse("<h1>Login failed</h1><p>Could not complete sign-in.</p>", status_code=400)
|
|
|
|
session = oidc.write_session(identity)
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax")
|
|
resp.delete_cookie("oidc_state")
|
|
resp.delete_cookie("oidc_nonce")
|
|
return resp
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout():
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.delete_cookie(oidc.SESSION_COOKIE)
|
|
return resp
|
|
|
|
|
|
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>Activated</th><th>Balance</th><th>Spend</th><th>Remaining</th><th>Reset</th><th>Adjust</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows"><tr><td colspan="8" 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';
|
|
const act = m.activated
|
|
? '<span class="pill active">yes</span>'
|
|
: '<span class="pill inactive">pending</span>';
|
|
return '<tr>' +
|
|
'<td>' + esc(m.email) + (m.username ? '<div style="color:var(--muted);font-size:11px">' + esc(m.username) + '</div>' : '') + '</td>' +
|
|
'<td><span class="pill ' + pill + '">' + pill + '</span></td>' +
|
|
'<td>' + act + '</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 data-email="' + esc(m.email) + '" onclick="addBalance(this.dataset.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)
|