Credits: column, pill display, and add-credits control (two-bucket budgets)

This commit is contained in:
inference-bot committed 2026-09-27 19:11:49 -06:00
1 parent c0157bac13
commit 4df546fbe9
1 file changed
+47 -2
+47 -2
View File
@@ -142,6 +142,27 @@ async def api_set_balance(request: Request):
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/set-credits")
async def api_set_credits(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, "reason": body.get("reason") or "admin adjustment"}
if "add" in body:
payload["add"] = float(body["add"])
elif "set" in body:
payload["set"] = float(body["set"])
else:
return JSONResponse({"error": "Provide 'set' or 'add'"}, status_code=400)
try:
return await portal_post("/admin/set-credits", 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).
@@ -240,6 +261,8 @@ PAGE_HTML = """<!DOCTYPE html>
.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; }
.adjust .credits-btn { background: var(--bronze, #a97a4a); }
.adjust .credits-btn:hover { background: #92693c; }
.tabs { display: flex; gap: 8px; margin: 28px 0 12px; align-items: center; }
.tabs h2 { font-size: 17px; font-weight: 700; margin-right: 8px; }
.tab-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 13px; font-weight: 600; padding: 6px 14px; }
@@ -282,7 +305,8 @@ PAGE_HTML = """<!DOCTYPE html>
<th class="sortable" onclick="sortMembers('email')">Member<span class="arrow" id="arrow-members-email"></span></th>
<th class="sortable" onclick="sortMembers('active')">Status<span class="arrow" id="arrow-members-active"></span></th>
<th class="sortable" onclick="sortMembers('activated')">Activated<span class="arrow" id="arrow-members-activated"></span></th>
<th class="sortable" onclick="sortMembers('balance')">Balance<span class="arrow" id="arrow-members-balance"></span></th>
<th class="sortable" onclick="sortMembers('balance')">Allowance<span class="arrow" id="arrow-members-balance"></span></th>
<th class="sortable" onclick="sortMembers('credit_balance')">Credits<span class="arrow" id="arrow-members-credit_balance"></span></th>
<th class="sortable" onclick="sortMembers('spend')">Spend<span class="arrow" id="arrow-members-spend"></span></th>
<th class="sortable" onclick="sortMembers('remaining')">Remaining<span class="arrow" id="arrow-members-remaining"></span></th>
<th class="sortable" onclick="sortMembers('reset_at')">Reset<span class="arrow" id="arrow-members-reset_at"></span></th>
@@ -422,23 +446,44 @@ function renderMembers() {
const act = m.activated
? '<span class="pill active">yes</span>'
: '<span class="pill inactive">pending</span>';
const credits = m.credit_balance ?? 0;
const creditsPill = credits > 0
? '<span class="pill active">' + fmt(credits) + '</span>'
: '<span style="color:var(--muted)">—</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>' + creditsPill + '</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>' +
'<button data-email="' + esc(m.email) + '" onclick="addBalance(this.dataset.email)" title="Add monthly allowance">Allow</button>' +
'<button data-email="' + esc(m.email) + '" class="credits-btn" onclick="addCredits(this.dataset.email)" title="Add non-expiring credits">Credit</button>' +
'</div></td>' +
'</tr>';
}).join('');
clearArrows('members', memberSort);
}
async function addCredits(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-credits', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ email, add: val, reason: 'admin adjustment (panel)' })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Added ' + fmt(val) + ' credits to ' + email, true); el.value = ''; load(); }
} catch (e) { flash(e.message, false); }
}
function renderModels() {
const rows = [...modelsData].sort((a, b) => {
const va = a[modelSort.key], vb = b[modelSort.key];