560 lines
22 KiB
Python
560 lines
22 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
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; }
|
|
.key-reveal {
|
|
margin-top: 16px; padding: 16px;
|
|
background: #eaf3ea; border: 1px solid var(--green-mid); border-radius: 10px;
|
|
}
|
|
.key-reveal-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
|
|
.key-reveal-name { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
|
.key-reveal-value {
|
|
display: block; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
font-size: 12px; background: #fff; padding: 8px 10px; border-radius: 6px;
|
|
word-break: break-all; margin-bottom: 10px; user-select: all;
|
|
}
|
|
.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 class="key-reveal" id="key-reveal" style="display:none;">
|
|
<div class="key-reveal-title">Copy your key now — it won't be shown again</div>
|
|
<div class="key-reveal-name" id="key-reveal-name"></div>
|
|
<code class="key-reveal-value" id="key-reveal-value"></code>
|
|
<button class="ghost" onclick="$('key-reveal').style.display='none';">Dismiss</button>
|
|
</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 fp = k.fingerprint || '';
|
|
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) +
|
|
(fp ? ' · ends …' + escapeHtml(fp) : '') + '</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 {
|
|
const token = d.sk_token || '';
|
|
if (token) {
|
|
// Reveal the key ONCE, inline, copyable. It is never shown again.
|
|
const box = $('key-reveal');
|
|
box.style.display = 'block';
|
|
$('key-reveal-value').textContent = token;
|
|
$('key-reveal-name').textContent = name;
|
|
navigator.clipboard?.writeText(token).catch(() => {});
|
|
}
|
|
$('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)
|