commit c8c6e23a285f9e157599c0014a3ccd52ef60743b Author: inference-bot Date: Mon Sep 14 16:41:55 2026 -0600 Admin panel: member overview + balance adjustment (proxyAuth, admin-gated) diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..622314d --- /dev/null +++ b/CloudronManifest.json @@ -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"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c25544 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ebbb8e1 --- /dev/null +++ b/app/main.py @@ -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( + "

Forbidden

This panel is restricted to administrators.

", + 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 = """ + + + + +Inference Cooperative · Admin + + + +
+
+
+

Inference Cooperative

+
Admin panel
+
+
+ +
+
—
members
+
—
active
+
—
total spend
+
+ +
+ + + + + + + + +
MemberStatusBalanceSpendRemainingResetAdjust
Loading…
+
+ + + +""" + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + return JSONResponse({"error": exc.detail}, status_code=exc.status_code) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27a489c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2