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
This commit is contained in:
@@ -78,6 +78,7 @@
|
|||||||
<th style="padding:12px 8px;">Renews</th>
|
<th style="padding:12px 8px;">Renews</th>
|
||||||
<th style="padding:12px 8px;">Created</th>
|
<th style="padding:12px 8px;">Created</th>
|
||||||
<th style="padding:12px 8px;">Last login</th>
|
<th style="padding:12px 8px;">Last login</th>
|
||||||
|
<th style="padding:12px 8px;">Tokens</th>
|
||||||
<th style="padding:12px 8px;">Actions</th>
|
<th style="padding:12px 8px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1713,7 +1713,7 @@
|
|||||||
if (!state.accounts.length) {
|
if (!state.accounts.length) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
const cell = document.createElement('td');
|
const cell = document.createElement('td');
|
||||||
cell.colSpan = 8;
|
cell.colSpan = 9;
|
||||||
cell.textContent = 'No accounts found.';
|
cell.textContent = 'No accounts found.';
|
||||||
cell.className = 'muted';
|
cell.className = 'muted';
|
||||||
cell.style.padding = '12px';
|
cell.style.padding = '12px';
|
||||||
@@ -1739,6 +1739,18 @@
|
|||||||
row.appendChild(cell);
|
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');
|
const actionsCell = document.createElement('td');
|
||||||
actionsCell.style.padding = '10px 8px';
|
actionsCell.style.padding = '10px 8px';
|
||||||
actionsCell.style.display = 'flex';
|
actionsCell.style.display = 'flex';
|
||||||
@@ -1750,6 +1762,13 @@
|
|||||||
changeBtn.addEventListener('click', () => changePlan(acct));
|
changeBtn.addEventListener('click', () => changePlan(acct));
|
||||||
actionsCell.appendChild(changeBtn);
|
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');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'danger';
|
deleteBtn.className = 'danger';
|
||||||
deleteBtn.textContent = 'Delete';
|
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() {
|
async function loadAccounts() {
|
||||||
if (!el.accountsTable) return;
|
if (!el.accountsTable) return;
|
||||||
setStatus('Loading accounts...');
|
setStatus('Loading accounts...');
|
||||||
|
|||||||
@@ -6181,12 +6181,16 @@ function ensureTokenUsageBucket(userId) {
|
|||||||
usage: 0,
|
usage: 0,
|
||||||
addOns: 0,
|
addOns: 0,
|
||||||
paygBilled: 0,
|
paygBilled: 0,
|
||||||
|
tokenOverride: null,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const entry = tokenUsage[key];
|
const entry = tokenUsage[key];
|
||||||
entry.usage = typeof entry.usage === 'number' ? entry.usage : 0;
|
entry.usage = typeof entry.usage === 'number' ? entry.usage : 0;
|
||||||
entry.addOns = typeof entry.addOns === 'number' ? entry.addOns : 0;
|
entry.addOns = typeof entry.addOns === 'number' ? entry.addOns : 0;
|
||||||
entry.paygBilled = typeof entry.paygBilled === 'number' ? entry.paygBilled : 0;
|
entry.paygBilled = typeof entry.paygBilled === 'number' ? entry.paygBilled : 0;
|
||||||
|
if (entry.tokenOverride !== null && typeof entry.tokenOverride !== 'number') {
|
||||||
|
entry.tokenOverride = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tokenUsage[key];
|
return tokenUsage[key];
|
||||||
}
|
}
|
||||||
@@ -7008,14 +7012,20 @@ function validateSubscriptionSelection(plan, billingCycle, currency) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPlanTokenLimits(plan, userId) {
|
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 normalized = normalizePlanSelection(plan) || DEFAULT_PLAN;
|
||||||
const base = (planTokenLimits && typeof planTokenLimits[normalized] === 'number') ? planTokenLimits[normalized] : (PLAN_TOKEN_LIMITS[DEFAULT_PLAN] || 0);
|
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));
|
return Math.max(0, Number(base || 0) + Number(bucket.addOns || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenUsageSummary(userId, plan) {
|
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 limit = getPlanTokenLimits(plan, userId);
|
||||||
const used = Math.max(0, Number(bucket.usage || 0));
|
const used = Math.max(0, Number(bucket.usage || 0));
|
||||||
const remaining = limit > 0 ? Math.max(0, limit - used) : 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,
|
percent: limit > 0 ? Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))) : 0,
|
||||||
addOn: Math.max(0, Number(bucket.addOns || 0)),
|
addOn: Math.max(0, Number(bucket.addOns || 0)),
|
||||||
plan: normalizePlanSelection(plan) || DEFAULT_PLAN,
|
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
|
// Affiliate management API endpoints
|
||||||
async function handleAdminAffiliatesList(req, res) {
|
async function handleAdminAffiliatesList(req, res) {
|
||||||
const session = requireAdminAuth(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/env-config') return handleAdminEnvConfig(req, res);
|
||||||
if (req.method === 'GET' && pathname === '/api/admin/accounts') return handleAdminAccountsList(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/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 === 'DELETE' && pathname === '/api/admin/accounts') return handleAdminAccountDelete(req, res);
|
||||||
if (req.method === 'GET' && pathname === '/api/admin/affiliates') return handleAdminAffiliatesList(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);
|
if (req.method === 'DELETE' && pathname === '/api/admin/affiliates') return handleAdminAffiliateDelete(req, res);
|
||||||
|
|||||||
Reference in New Issue
Block a user