Files
member-dashboard/app/main.py
T

535 lines
21 KiB
Python

"""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-Remote-User header (username).
- 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.
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
from app import oidc
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 OIDC session cookie (username). proxyAuth header is the
# legacy fallback during the transition.
# ---------------------------------------------------------------------------
def get_user_identity(request: Request) -> str:
"""Return the logged-in user's identity (Cloudron username).
Reads the validated OIDC session cookie. No header fallback — the app no
longer trusts a client-supplied identity header (that path existed only
during the proxyAuth → OIDC transition and is now removed).
"""
if not oidc.is_oidc_configured():
return ""
token = request.cookies.get(oidc.SESSION_COOKIE)
if not token:
return ""
return oidc.read_session(token) or ""
# ---------------------------------------------------------------------------
# Broker calls (to the member portal)
# ---------------------------------------------------------------------------
def broker_headers(identity: str) -> dict:
return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity}
async def broker_get(identity: str, path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
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(identity: str, path: str, payload: dict | None = None) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), 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(identity: str, path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
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):
identity = get_user_identity(request)
if not identity:
if oidc.is_oidc_configured():
return RedirectResponse("/auth/openid/login", status_code=302)
return HTMLResponse(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
status_code=401,
)
return HTMLResponse(render_page(identity))
@app.get("/api/usage")
async def api_usage(request: Request):
identity = get_user_identity(request)
if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
data = await broker_get(identity, "/broker/usage")
keys = await broker_get(identity, "/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):
identity = get_user_identity(request)
if not identity:
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(identity, "/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):
identity = get_user_identity(request)
if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
return await broker_delete(identity, f"/broker/keys/{name}")
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)
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
# ---------------------------------------------------------------------------
# UI (brand-matched, self-contained, no external requests)
# ---------------------------------------------------------------------------
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def render_page(identity: str) -> str:
return PAGE_HTML.replace("@@IDENTITY@@", _esc(identity))
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 · Your membership</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;
display: flex; flex-direction: column; min-height: 100vh;
}
.wrap { width: 100%; max-width: 760px; margin: 0 auto; padding: 32px 24px 24px; flex: 1; }
header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 8px; }
.logo svg { display: block; }
.brand { flex: 1; }
.brand h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.brand .sub { color: var(--muted); font-size: 14px; }
.identity { margin-top: 6px; font-size: 13px; color: var(--muted); }
.identity code {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
background: #f4f2ec; padding: 1px 6px; border-radius: 5px; font-size: 12px;
}
.header-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
a.link { color: var(--green-deep); text-decoration: none; font-size: 14px; font-weight: 600; }
a.link:hover { text-decoration: underline; }
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px 28px;
margin-top: 20px;
}
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.balance { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.balance .big { font-size: 34px; font-weight: 700; color: var(--green-deep); }
.balance .lbl { color: var(--muted); font-size: 15px; }
.stats { display: flex; gap: 28px; margin-top: 12px; flex-wrap: wrap; }
.stat .num { font-size: 18px; font-weight: 700; color: var(--ink); }
.stat .lbl { font-size: 12px; color: var(--muted); }
.bar { height: 10px; background: #eef0ea; border-radius: 6px; margin-top: 16px; 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; }
.models { margin-top: 16px; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 9px 0; border-top: 1px solid #f0ede6;
}
.model-name { font-weight: 600; font-size: 13px; }
.model-sub { color: var(--muted); font-size: 12px; }
.model-spend { font-size: 13px; font-weight: 600; color: var(--ink); text-align: right; }
.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); }
footer {
border-top: 1px solid var(--border);
padding: 20px 24px; margin-top: 32px;
text-align: center; font-size: 13px; color: var(--muted);
}
footer a { color: var(--green-deep); text-decoration: none; margin: 0 8px; }
footer a:hover { text-decoration: underline; }
</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>
<div class="sub">Your membership</div>
<div class="identity" id="identity">Signed in as <code>@@IDENTITY@@</code></div>
</div>
<div class="header-actions">
<a class="link" href="https://my.inference.coop">Member Home</a>
<a class="link" href="/logout">Log out</a>
</div>
</header>
<div class="card">
<h2>Usage</h2>
<div class="balance">
<span class="big" id="spend">—</span>
<span class="lbl" id="of">of your balance used</span>
</div>
<div class="stats">
<div class="stat"><div class="num" id="remaining">—</div><div class="lbl">remaining</div></div>
<div class="stat"><div class="num" id="tokens">—</div><div class="lbl">tokens used</div></div>
<div class="stat"><div class="num" id="calls">—</div><div class="lbl">requests</div></div>
</div>
<div class="bar"><div class="fill" id="fill" style="width:0%"></div></div>
<div class="meta" id="meta">Shared across chat and API.</div>
<div class="models" id="models"></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">No API keys yet.</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>
<footer>
<a href="https://git.inference.coop/co-op/docs/src/branch/main/README.md">Docs</a> ·
<a href="https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md">Terms</a> ·
<a href="https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md">Privacy</a>
<div style="margin-top:6px;">The Inference Cooperative · inference.coop</div>
</footer>
<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 fmtMoney(n) { return '$' + (Number(n) || 0).toFixed(2); }
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 = fmtMoney(spend);
$('of').textContent = 'of ' + fmtMoney(balance) + ' used';
$('remaining').textContent = fmtMoney(remaining);
$('fill').style.width = pct + '%';
$('fill').className = 'fill' + (pct > 80 ? ' warn' : '');
// Renewal date (when the next round of credits lands)
if (d.reset_at) {
const d2 = new Date(d.reset_at);
const opts = { year: 'numeric', month: 'long', day: 'numeric' };
$('meta').textContent = 'Shared across chat and API · Credits renew ' + d2.toLocaleDateString(undefined, opts) + '.';
}
const tokens = d.total_tokens ?? 0;
const calls = (d.models || []).reduce((s, m) => s + (m.calls || 0), 0);
$('tokens').textContent = tokens.toLocaleString();
$('calls').textContent = calls.toLocaleString();
renderModels(d.models || []);
renderKeys(d.keys || []);
} catch (e) {
flash('Could not load usage: ' + e.message, false);
}
}
function renderModels(models) {
const el = $('models');
if (!models.length) { el.innerHTML = ''; return; }
el.innerHTML = models.map(m => {
const sub = (m.calls || 0).toLocaleString() + ' requests · ' + (m.tokens || 0).toLocaleString() + ' tokens';
return '<div class="model-row">' +
'<div><div class="model-name">' + escapeHtml(m.model) + '</div>' +
'<div class="model-sub">' + escapeHtml(sub) + '</div></div>' +
'<div class="model-spend">' + fmtMoney(m.spend) + '</div>' +
'</div>';
}).join('');
}
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" data-name="' + escapeHtml(k.name) + '" onclick="revokeKey(this.dataset.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)