Member dashboard: usage view + API key management (proxyAuth SSO, broker-backed)

This commit is contained in:
inference-bot committed 2026-09-14 11:01:39 -06:00
commit 5264c3e9f7
4 files changed
+458

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
{
"id": "coop.inference.member-dashboard",
"title": "Member Dashboard",
"author": "Inference Cooperative",
"description": "Member-facing dashboard: view usage, manage API keys. Reads from the member portal; holds no privileged credentials.",
"tagline": "Your Inference Cooperative account",
"version": "0.1.0",
"healthCheckPath": "/healthz",
"httpPort": 8000,
"addons": {
"localstorage": {},
"proxyAuth": {}
},
"manifestVersion": 2,
"website": "https://inference.coop",
"contactEmail": "info@inference.coop",
"tags": ["member", "dashboard", "cooperative"]
}
+14
View File
@@ -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"]
+422
View File
@@ -0,0 +1,422 @@
"""Member Dashboard — read-only usage view + API-key management.
Security model:
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
the authenticated user's identity via the X-Forwarded-User header.
- This app holds NO privileged credentials. It calls the member portal's
broker endpoints, authenticated with a shared BROKER_SECRET, and scopes
every request to the logged-in member's email.
Environment (all provided by Cloudron env vars):
PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop)
BROKER_SECRET — shared secret for the portal's broker endpoints
"""
import os
import logging
import re
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("member-dashboard")
PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/")
BROKER_SECRET = os.environ.get("BROKER_SECRET", "")
app = FastAPI()
# ---------------------------------------------------------------------------
# Identity — Cloudron proxyAuth injects the authenticated user's email.
# ---------------------------------------------------------------------------
def get_user_email(request: Request) -> str:
"""Return the logged-in user's email from Cloudron's proxyAuth headers.
Cloudron's nginx auth-request module injects X-Forwarded-User. We also
accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact
header set has varied across Cloudron versions.
"""
for header in (
"x-forwarded-user",
"x-remote-user",
"x-auth-request-user",
"x-auth-request-email",
"x-forwarded-email",
):
val = request.headers.get(header)
if val:
val = val.strip().lower()
# Some proxies prefix "user:" or use a bare username; emails are the
# canonical identity here.
if "@" in val:
return val
logger.warning("Header %s had no email (@): %r", header, val)
# Last resort: Cloudron also sets x-auth-request-email.
logger.warning("No identity header found; user unknown")
return ""
def is_trusted(request: Request) -> bool:
"""Only trust identity headers from Cloudron's proxy (behind us)."""
# Cloudron injects these headers itself; we trust them because the app is
# only reachable through Cloudron's proxy. This is a defense-in-depth note;
# the app is not directly exposed.
return True
# ---------------------------------------------------------------------------
# Broker calls (to the member portal)
# ---------------------------------------------------------------------------
async def broker_get(email: str, path: str) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}", headers=headers)
if r.status_code == 401:
raise HTTPException(500, "Portal rejected broker credentials")
if r.status_code == 403:
raise HTTPException(403, "Not an active member")
if r.status_code != 200:
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json()
async def broker_post(email: str, path: str, payload: dict | None = None) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", headers=headers, json=payload or {})
if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 201):
raise HTTPException(502, f"Portal error {r.status_code}: {r.text}")
return r.json()
async def broker_delete(email: str, path: str) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.delete(f"{PORTAL_BASE}{path}", headers=headers)
if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 204):
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json() if r.content else {}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/")
async def index(request: Request):
email = get_user_email(request)
if not email:
return HTMLResponse(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
status_code=401,
)
return HTMLResponse(render_page(email))
@app.get("/api/usage")
async def api_usage(request: Request):
email = get_user_email(request)
if not email:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
data = await broker_get(email, "/broker/usage")
keys = await broker_get(email, "/broker/keys")
data["keys"] = keys.get("keys", [])
return data
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/keys")
async def api_create_key(request: Request):
email = get_user_email(request)
if not email:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
body = await request.json()
name = (body.get("name") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
return JSONResponse({"error": "Invalid key name"}, status_code=400)
try:
return await broker_post(email, "/broker/keys", {"name": name})
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.delete("/api/keys/{name}")
async def api_revoke_key(name: str, request: Request):
email = get_user_email(request)
if not email:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
return await broker_delete(email, f"/broker/keys/{name}")
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
# ---------------------------------------------------------------------------
# UI (brand-matched, self-contained, no external requests)
# ---------------------------------------------------------------------------
def render_page(email: str) -> str:
return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inference Cooperative · Your account</title>
<style>
:root {
--cream: #faf8f5;
--ink: #2d3327;
--muted: #6b7a62;
--green-deep: #5b8c5a;
--green-mid: #6ba86b;
--green-light: #7ab87a;
--border: #e4e0d8;
--danger: #b3543a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--cream);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 720px; margin: 0 auto; padding: 40px 24px 64px; }
header { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
.logo svg { display: block; }
.brand h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.brand p { color: var(--muted); font-size: 14px; }
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
margin-top: 24px;
}
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
.balance {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.balance .spend { font-size: 34px; font-weight: 700; color: var(--green-deep); }
.balance .of { color: var(--muted); font-size: 15px; }
.bar { height: 10px; background: #eef0ea; border-radius: 6px; margin-top: 14px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--green-mid); border-radius: 6px; transition: width .3s ease; }
.bar .fill.warn { background: #d99a3e; }
.meta { color: var(--muted); font-size: 13px; margin-top: 8px; }
.keys { margin-top: 8px; }
.key-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 0; border-bottom: 1px solid var(--border);
}
.key-row:last-child { border-bottom: none; }
.key-name { font-weight: 600; font-size: 14px; }
.key-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.key-token {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; color: var(--muted);
background: #f4f2ec; padding: 4px 8px; border-radius: 6px;
word-break: break-all; max-width: 280px;
}
button {
font-family: inherit; cursor: pointer;
border-radius: 8px; font-size: 14px; font-weight: 600;
padding: 8px 16px; border: none; transition: background .15s ease, opacity .15s ease;
}
button.primary { background: var(--green-deep); color: #fff; }
button.primary:hover { background: #4e7a4d; }
button.ghost { background: transparent; color: var(--danger); }
button.ghost:hover { background: #f6e9e5; }
button:disabled { opacity: .5; cursor: not-allowed; }
.new-key { display: flex; gap: 10px; margin-top: 16px; }
.new-key input {
flex: 1; font-family: inherit; font-size: 14px;
padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--ink);
}
.new-key input:focus { outline: none; border-color: var(--green-mid); }
.hint { color: var(--muted); font-size: 12px; margin-top: 10px; }
.empty { color: var(--muted); font-size: 14px; padding: 12px 0; }
.flash {
padding: 12px 16px; border-radius: 8px; margin-top: 16px; font-size: 14px;
display: none;
}
.flash.show { display: block; }
.flash.ok { background: #eaf3ea; color: #2f5c30; }
.flash.err { background: #f6e9e5; color: var(--danger); }
code.inline {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; background: #f4f2ec; padding: 1px 5px; border-radius: 4px;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">
<svg width="44" height="44" viewBox="0 0 130 130" fill="none" aria-label="logo">
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
<circle cx="58" cy="30" r="6" fill="#7ab87a"/>
<circle cx="74" cy="30" r="4" fill="#6ba86b"/>
<circle cx="51.5" cy="46" r="6.5" fill="#5b8c5a"/>
<circle cx="65" cy="46" r="5" fill="#7ab87a"/>
<circle cx="81" cy="46" r="4" fill="#6ba86b"/>
<circle cx="44" cy="62" r="7" fill="#6ba86b"/>
<circle cx="61" cy="62" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="62" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="62" r="3.5" fill="#6ba86b"/>
<circle cx="36.5" cy="78" r="7.5" fill="#5b8c5a"/>
<circle cx="54" cy="78" r="6" fill="#7ab87a"/>
<circle cx="70" cy="78" r="5" fill="#6ba86b"/>
<circle cx="84" cy="78" r="4" fill="#5b8c5a"/>
<circle cx="97.5" cy="78" r="3.5" fill="#7ab87a"/>
<circle cx="44" cy="94" r="6.5" fill="#6ba86b"/>
<circle cx="61" cy="94" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="94" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="94" r="3.5" fill="#6ba86b"/>
<circle cx="51.5" cy="110" r="5.5" fill="#5b8c5a"/>
<circle cx="65" cy="110" r="4.5" fill="#7ab87a"/>
<circle cx="81" cy="110" r="3.5" fill="#6ba86b"/>
</svg>
</div>
<div class="brand">
<h1>Inference Cooperative</h1>
<p>Your account</p>
</div>
</header>
<div class="card">
<h2>Usage</h2>
<div class="balance">
<span class="spend" id="spend">—</span>
<span class="of" id="of">of your balance used</span>
</div>
<div class="bar"><div class="fill" id="fill" style="width:0%"></div></div>
<div class="meta" id="meta">Loading…</div>
</div>
<div class="card">
<h2>API keys</h2>
<p class="hint">Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.</p>
<div class="keys" id="keys"><div class="empty">Loading…</div></div>
<div class="new-key">
<input id="new-name" type="text" placeholder="Key name (e.g. my-script)" maxlength="64" />
<button class="primary" id="create-btn" onclick="createKey()">Create key</button>
</div>
<div class="flash" id="flash"></div>
</div>
</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);
}
async function load() {
try {
const r = await fetch('/api/usage');
if (r.status === 401) { location.reload(); return; }
const d = await r.json();
if (d.error) { flash(d.error, false); return; }
const balance = d.balance ?? 0;
const spend = d.spend ?? 0;
const remaining = d.remaining ?? Math.max(0, balance - spend);
const pct = balance > 0 ? Math.min(100, (spend / balance) * 100) : 0;
$('spend').textContent = '$' + spend.toFixed(2);
$('of').textContent = 'of $' + balance.toFixed(2) + ' used · $' + remaining.toFixed(2) + ' left';
$('fill').style.width = pct + '%';
$('fill').className = 'fill' + (pct > 80 ? ' warn' : '');
$('meta').textContent = 'Shared across chat and API. Top-ups appear here automatically.';
renderKeys(d.keys || []);
} catch (e) {
flash('Could not load usage: ' + e.message, false);
}
}
function renderKeys(keys) {
const el = $('keys');
if (!keys.length) {
el.innerHTML = '<div class="empty">No API keys yet. Create one below.</div>';
return;
}
el.innerHTML = keys.map(k => {
const token = k.sk_token || '';
const created = k.created_at ? ' · created ' + k.created_at.slice(0,10) : '';
return '<div class="key-row">' +
'<div><div class="key-name">' + escapeHtml(k.name) + '</div>' +
'<div class="key-meta">' + escapeHtml(created) + '</div>' +
(token ? '<div class="key-token">' + escapeHtml(token) + '</div>' : '') +
'</div>' +
'<button class="ghost" onclick="revokeKey(\'' + escapeHtml(k.name) + '\')">Revoke</button>' +
'</div>';
}).join('');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function createKey() {
const name = $('new-name').value.trim();
if (!name) { flash('Enter a name for the key', false); return; }
$('create-btn').disabled = true;
try {
const r = await fetch('/api/keys', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else {
flash('Key created — copy it now, it won\u2019t be shown again.', true);
$('new-name').value = '';
load();
}
} catch (e) { flash(e.message, false); }
$('create-btn').disabled = false;
}
async function revokeKey(name) {
if (!confirm('Revoke key "' + name + '"?')) return;
try {
const r = await fetch('/api/keys/' + encodeURIComponent(name), { method: 'DELETE' });
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Key revoked.', true); load(); }
} catch (e) { flash(e.message, false); }
}
load();
</script>
</body>
</html>"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if exc.status_code == 403:
return JSONResponse({"error": exc.detail}, status_code=403)
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2
jinja2==3.1.4