From dafd3c796dd1a2ac364e5745d20511cb01561091 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Sun, 15 Feb 2026 16:19:06 +0000 Subject: [PATCH] Add admin token override feature for user accounts - Add tokenOverride field to token usage bucket - Create POST /api/admin/accounts/tokens endpoint for setting manual token limits - Update admin accounts page to display token usage and override status - Add 'Set Tokens' button to manually override user token limits for the month - Override takes precedence over plan-based limits when set --- chat/public/admin-accounts.html | 1 + chat/public/admin.js | 61 ++++++++++++++++++++++++++++++++- chat/server.js | 49 ++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/chat/public/admin-accounts.html b/chat/public/admin-accounts.html index dd397dc..f058ab6 100644 --- a/chat/public/admin-accounts.html +++ b/chat/public/admin-accounts.html @@ -78,6 +78,7 @@ Renews Created Last login + Tokens Actions diff --git a/chat/public/admin.js b/chat/public/admin.js index 8fa2c3c..e2a7b48 100644 --- a/chat/public/admin.js +++ b/chat/public/admin.js @@ -1713,7 +1713,7 @@ if (!state.accounts.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); - cell.colSpan = 8; + cell.colSpan = 9; cell.textContent = 'No accounts found.'; cell.className = 'muted'; cell.style.padding = '12px'; @@ -1739,6 +1739,18 @@ row.appendChild(cell); }); + const tokenUsage = acct.tokenUsage || {}; + const tokensUsed = tokenUsage.used || 0; + const tokensLimit = tokenUsage.limit || 0; + const tokenOverride = tokenUsage.tokenOverride; + const tokensDisplay = `${tokensUsed.toLocaleString()} / ${tokensLimit.toLocaleString()}${tokenOverride !== null && tokenOverride !== undefined ? ' (override)' : ''}`; + + const tokenCell = document.createElement('td'); + tokenCell.style.padding = '10px 8px'; + tokenCell.textContent = tokensDisplay; + tokenCell.title = tokenOverride !== null && tokenOverride !== undefined ? `Manual override: ${tokenOverride.toLocaleString()} tokens` : 'Plan-based limit'; + row.appendChild(tokenCell); + const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; @@ -1750,6 +1762,13 @@ changeBtn.addEventListener('click', () => changePlan(acct)); actionsCell.appendChild(changeBtn); + const tokensBtn = document.createElement('button'); + tokensBtn.className = 'ghost'; + tokensBtn.textContent = 'Set Tokens'; + tokensBtn.title = 'Manually set token limit for this user'; + tokensBtn.addEventListener('click', () => setTokens(acct)); + actionsCell.appendChild(tokensBtn); + const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = 'Delete'; @@ -1830,6 +1849,46 @@ } } + async function setTokens(acct) { + const tokenUsage = acct.tokenUsage || {}; + const currentLimit = tokenUsage.limit || 0; + const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined; + const currentOverride = hasOverride ? tokenUsage.tokenOverride : ''; + + const promptMessage = `Set token limit for ${acct.email}\n\n` + + `Current plan: ${acct.plan || 'starter'}\n` + + `Current limit: ${currentLimit.toLocaleString()} tokens\n` + + `${hasOverride ? `Current override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` + + `Enter new token limit (0 to remove override, or a number to set manual limit):`; + + const tokenInput = prompt(promptMessage, currentOverride); + + if (tokenInput === null) return; + + const tokens = parseInt(tokenInput.trim(), 10); + if (isNaN(tokens) || tokens < 0) { + alert('Invalid token amount. Please enter a non-negative number.'); + return; + } + + if (!confirm(`Are you sure you want to set ${acct.email}'s token limit to ${tokens.toLocaleString()}? This will override their plan-based limit for this month.`)) { + return; + } + + setStatus(`Updating token limit for ${acct.email}...`); + try { + await api('/api/admin/accounts/tokens', { + method: 'POST', + body: JSON.stringify({ userId: acct.id, tokens: tokens }) + }); + setStatus('Token limit updated successfully'); + await loadAccounts(); + setTimeout(() => setStatus(''), 3000); + } catch (err) { + setStatus(err.message, true); + } + } + async function loadAccounts() { if (!el.accountsTable) return; setStatus('Loading accounts...'); diff --git a/chat/server.js b/chat/server.js index 9e157e3..6d72c8e 100644 --- a/chat/server.js +++ b/chat/server.js @@ -6181,12 +6181,16 @@ function ensureTokenUsageBucket(userId) { usage: 0, addOns: 0, paygBilled: 0, + tokenOverride: null, }; } else { const entry = tokenUsage[key]; entry.usage = typeof entry.usage === 'number' ? entry.usage : 0; entry.addOns = typeof entry.addOns === 'number' ? entry.addOns : 0; entry.paygBilled = typeof entry.paygBilled === 'number' ? entry.paygBilled : 0; + if (entry.tokenOverride !== null && typeof entry.tokenOverride !== 'number') { + entry.tokenOverride = null; + } } return tokenUsage[key]; } @@ -7008,14 +7012,20 @@ function validateSubscriptionSelection(plan, billingCycle, currency) { } function getPlanTokenLimits(plan, userId) { + const bucket = ensureTokenUsageBucket(userId) || { addOns: 0, tokenOverride: null }; + + // Check for manual token override first + if (typeof bucket.tokenOverride === 'number' && bucket.tokenOverride >= 0) { + return Math.max(0, bucket.tokenOverride); + } + const normalized = normalizePlanSelection(plan) || DEFAULT_PLAN; const base = (planTokenLimits && typeof planTokenLimits[normalized] === 'number') ? planTokenLimits[normalized] : (PLAN_TOKEN_LIMITS[DEFAULT_PLAN] || 0); - const bucket = ensureTokenUsageBucket(userId) || { addOns: 0 }; return Math.max(0, Number(base || 0) + Number(bucket.addOns || 0)); } function getTokenUsageSummary(userId, plan) { - const bucket = ensureTokenUsageBucket(userId) || { usage: 0, addOns: 0 }; + const bucket = ensureTokenUsageBucket(userId) || { usage: 0, addOns: 0, tokenOverride: null }; const limit = getPlanTokenLimits(plan, userId); const used = Math.max(0, Number(bucket.usage || 0)); const remaining = limit > 0 ? Math.max(0, limit - used) : 0; @@ -7027,6 +7037,7 @@ function getTokenUsageSummary(userId, plan) { percent: limit > 0 ? Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))) : 0, addOn: Math.max(0, Number(bucket.addOns || 0)), plan: normalizePlanSelection(plan) || DEFAULT_PLAN, + tokenOverride: bucket.tokenOverride !== undefined ? bucket.tokenOverride : null, }; } @@ -16005,6 +16016,39 @@ async function handleAdminAccountDelete(req, res) { } } +// Admin account token override endpoint +async function handleAdminAccountTokensPost(req, res) { + const session = requireAdminAuth(req, res); + if (!session) return; + try { + const body = await parseJsonBody(req); + const userId = body.userId; + const tokenAmount = body.tokens; + + if (!userId) return sendJson(res, 400, { error: 'User ID is required' }); + if (typeof tokenAmount !== 'number' || tokenAmount < 0 || !Number.isFinite(tokenAmount)) { + return sendJson(res, 400, { error: 'Invalid token amount. Must be a non-negative number.' }); + } + + const user = findUserById(userId); + if (!user) return sendJson(res, 404, { error: 'User not found' }); + + const bucket = ensureTokenUsageBucket(userId); + if (!bucket) return sendJson(res, 500, { error: 'Failed to access token usage data' }); + + // Set the token override + bucket.tokenOverride = Math.floor(tokenAmount); + + await persistTokenUsage(); + + const accountData = await serializeAccount(user); + log('Admin set user token override', { userId, tokens: bucket.tokenOverride, admin: ADMIN_USER }); + sendJson(res, 200, { ok: true, account: accountData, tokenOverride: bucket.tokenOverride }); + } catch (error) { + sendJson(res, 400, { error: error.message || 'Unable to update token limit' }); + } +} + // Affiliate management API endpoints async function handleAdminAffiliatesList(req, res) { const session = requireAdminAuth(req, res); @@ -18583,6 +18627,7 @@ async function routeInternal(req, res, url, pathname) { if (req.method === 'GET' && pathname === '/api/admin/env-config') return handleAdminEnvConfig(req, res); if (req.method === 'GET' && pathname === '/api/admin/accounts') return handleAdminAccountsList(req, res); if (req.method === 'POST' && pathname === '/api/admin/accounts/plan') return handleAdminAccountPlanUpdate(req, res); + if (req.method === 'POST' && pathname === '/api/admin/accounts/tokens') return handleAdminAccountTokensPost(req, res); if (req.method === 'DELETE' && pathname === '/api/admin/accounts') return handleAdminAccountDelete(req, res); if (req.method === 'GET' && pathname === '/api/admin/affiliates') return handleAdminAffiliatesList(req, res); if (req.method === 'DELETE' && pathname === '/api/admin/affiliates') return handleAdminAffiliateDelete(req, res);