librechat-balances/public/js/app.js
DJP a9c514d5b1 Add history tracking and user history view
- All balance changes (top-ups, sets, bulk ops, request approvals)
  are now logged to data/history.json
- New "History" tab in admin sidebar
- Search by email to see all changes for a user with totals
- Partial name search shows matching users with summary stats
- Each entry shows action type, amount, source, OMG job #,
  resulting balance, and timestamp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:33:08 -04:00

646 lines
25 KiB
JavaScript

const app = {
apiKey: localStorage.getItem('bm_api_key') || '',
currentView: 'home',
currentPage: 1,
allData: null,
searchTimeout: null,
init() {
if (!this.apiKey) {
this.promptApiKey();
} else {
this.loadHome();
}
this.bindNav();
this.bindSearch();
this.bindHistorySearch();
this.bindModalClose();
this.bindTableActions();
},
promptApiKey() {
const key = prompt('Enter your API key:');
if (key) {
this.apiKey = key;
localStorage.setItem('bm_api_key', key);
this.loadHome();
}
},
async api(path, options = {}) {
const base = document.baseURI.replace(/\/$/, '');
const url = `${base}/api${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
...options.headers,
},
});
if (res.status === 401) {
localStorage.removeItem('bm_api_key');
this.apiKey = '';
this.promptApiKey();
return null;
}
return res.json();
},
bindNav() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => this.navigate(item.dataset.view));
});
document.querySelectorAll('.home-card[data-nav]').forEach(card => {
card.addEventListener('click', () => this.navigate(card.dataset.nav));
});
},
bindSearch() {
const input = document.getElementById('search-input');
input.addEventListener('input', () => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.doSearch(input.value), 300);
});
},
bindModalClose() {
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') this.closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.closeModal();
});
},
bindTableActions() {
document.addEventListener('click', (e) => {
const balanceBtn = e.target.closest('[data-action]');
if (balanceBtn) {
const { action, userid, email, credits } = balanceBtn.dataset;
this.showEditModal(userid, email, parseInt(credits), action);
return;
}
const reqBtn = e.target.closest('[data-req-action]');
if (reqBtn) {
const { reqAction, reqid, amount, email } = reqBtn.dataset;
if (reqAction === 'custom') {
this.showCustomApproveModal(reqid, email || '');
} else {
this.handleRequestAction(reqid, reqAction, amount ? parseInt(amount) : null);
}
return;
}
const histBtn = e.target.closest('[data-history-email]');
if (histBtn) {
const email = histBtn.dataset.historyEmail;
document.getElementById('history-search').value = email;
this.loadUserHistory(email);
}
});
},
navigate(view) {
this.currentView = view;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById(`view-${view}`).style.display = 'block';
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-view="${view}"]`).classList.add('active');
if (view === 'home') this.loadHome();
if (view === 'all') this.loadAll();
if (view === 'low') this.loadLow(1000000);
if (view === 'requests') this.loadRequests('pending');
if (view === 'history') document.getElementById('history-search').focus();
if (view === 'search') document.getElementById('search-input').focus();
},
async loadHome() {
this.updateRequestBadge();
const stats = await this.api('/stats');
if (!stats) return;
document.getElementById('home-stats').innerHTML = `
<div class="stat-card">
<div class="stat-label">Total Users</div>
<div class="stat-value">${stats.totalUsers.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">With Balance</div>
<div class="stat-value green">${stats.usersWithBalance.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Zero Balance</div>
<div class="stat-value red">${stats.zeroBalance.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Balance</div>
<div class="stat-value purple">${stats.avgCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Credits</div>
<div class="stat-value">${stats.totalCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Min Balance</div>
<div class="stat-value red">${stats.minCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Max Balance</div>
<div class="stat-value green">${stats.maxCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">No Balance Record</div>
<div class="stat-value red">${stats.usersWithoutBalance.toLocaleString()}</div>
</div>
`;
},
async loadAll(page = 1) {
this.currentPage = page;
const data = await this.api(`/balances?page=${page}&limit=50`);
if (!data) return;
this.allData = data;
document.getElementById('all-count').textContent = `${data.total} Users`;
document.getElementById('all-tbody').innerHTML = data.users.map(u => this.userRow(u)).join('');
this.renderPagination(data);
},
async loadLow(threshold) {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.threshold) === threshold);
});
const data = await this.api(`/balances/low?threshold=${threshold}`);
if (!data) return;
const tbody = document.getElementById('low-tbody');
document.getElementById('low-count').textContent = `${data.users.length} User${data.users.length !== 1 ? 's' : ''} under ${(threshold / 1000000)}M`;
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users below this threshold</td></tr>';
} else {
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
}
},
userRow(u) {
const credits = u.tokenCredits || 0;
const balanceClass = credits > 0 ? 'balance-positive' : 'balance-zero';
const name = u.name || u.username || '-';
const email = u.email || '-';
const safeEmail = email.replace(/"/g, '&quot;');
return `
<tr>
<td class="email-cell">${this.esc(email)}</td>
<td class="name-cell">${this.esc(name)}</td>
<td class="balance-cell ${balanceClass}">${credits.toLocaleString()}</td>
<td>
<div class="actions-cell">
<button class="btn btn-sm btn-green" data-action="add" data-userid="${u.userId}" data-email="${safeEmail}" data-credits="${credits}">Top Up</button>
<button class="btn btn-sm btn-secondary" data-action="set" data-userid="${u.userId}" data-email="${safeEmail}" data-credits="${credits}">Set</button>
</div>
</td>
</tr>
`;
},
renderPagination(data) {
const el = document.getElementById('pagination');
if (data.totalPages <= 1) {
el.innerHTML = '';
return;
}
el.innerHTML = `
<button ${data.page <= 1 ? 'disabled' : ''} onclick="app.loadAll(${data.page - 1})">Prev</button>
<span class="page-info">Page ${data.page} of ${data.totalPages}</span>
<button ${data.page >= data.totalPages ? 'disabled' : ''} onclick="app.loadAll(${data.page + 1})">Next</button>
`;
},
async doSearch(query) {
const container = document.getElementById('search-results-container');
const tbody = document.getElementById('search-tbody');
if (!query || query.length < 2) {
container.style.display = 'none';
return;
}
const data = await this.api(`/balances/search?q=${encodeURIComponent(query)}`);
if (!data) return;
container.style.display = 'block';
document.getElementById('search-count').textContent = `${data.users.length} Result${data.users.length !== 1 ? 's' : ''}`;
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users found</td></tr>';
} else {
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
}
},
showEditModal(userId, email, currentBalance, mode) {
const isAdd = mode === 'add';
const modal = document.getElementById('modal');
modal.innerHTML = `
<h3>${isAdd ? 'Top Up Balance' : 'Set Balance'}</h3>
<div class="modal-subtitle">${email}</div>
<div class="modal-field">
<label>Current Balance</label>
<div class="current-balance">${currentBalance.toLocaleString()}</div>
</div>
<div class="modal-field">
<label>${isAdd ? 'Amount to Add' : 'New Balance'}</label>
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
<div class="bulk-presets" style="margin-top:10px">
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="app.submitEdit('${userId}', '${mode}')">
${isAdd ? 'Add Tokens' : 'Set Balance'}
</button>
</div>
`;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
},
async submitEdit(userId, mode) {
const amount = parseInt(document.getElementById('modal-amount').value);
if (isNaN(amount)) {
this.toast('Please enter a valid number', 'error');
return;
}
if (mode === 'set' && amount < 0) {
this.toast('Balance cannot be negative', 'error');
return;
}
const endpoint = mode === 'add' ? 'add' : 'set';
const result = await this.api(`/balances/${userId}/${endpoint}`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (result && !result.error) {
this.toast(`Balance updated to ${result.tokenCredits.toLocaleString()}`, 'success');
this.closeModal();
if (this.currentView === 'all') this.loadAll(this.currentPage);
if (this.currentView === 'search') {
const q = document.getElementById('search-input').value;
if (q) this.doSearch(q);
}
if (this.currentView === 'home') this.loadHome();
} else {
this.toast(result?.error || 'Failed to update', 'error');
}
},
showBulkModal(mode) {
const isAdd = mode === 'add';
const modal = document.getElementById('modal');
modal.innerHTML = `
<h3>${isAdd ? 'Add Tokens to All Users' : 'Set All Users Balance'}</h3>
<div class="modal-subtitle">${isAdd
? 'This will increment every user\'s balance by the specified amount.'
: 'This will set every user\'s balance to the exact amount. Existing balances will be overwritten.'
}</div>
<div class="modal-field">
<label>${isAdd ? 'Amount to Add' : 'New Balance for All'}</label>
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
<div class="bulk-presets" style="margin-top:10px">
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn ${isAdd ? 'btn-green' : 'btn-red'}" onclick="app.submitBulk('${mode}')">
${isAdd ? 'Add to All' : 'Set All Balances'}
</button>
</div>
`;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
},
async submitBulk(mode) {
const amount = parseInt(document.getElementById('modal-amount').value);
if (isNaN(amount)) {
this.toast('Please enter a valid number', 'error');
return;
}
if (mode === 'set' && amount < 0) {
this.toast('Balance cannot be negative', 'error');
return;
}
const confirmed = confirm(`Are you sure you want to ${mode === 'add' ? 'add ' + amount.toLocaleString() + ' tokens to' : 'set'} ALL users${mode === 'set' ? ' to ' + amount.toLocaleString() : ''}?`);
if (!confirmed) return;
const result = await this.api(`/balances/bulk/${mode}`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (result && !result.error) {
this.toast(`Updated ${result.modifiedCount} users`, 'success');
this.closeModal();
} else {
this.toast(result?.error || 'Bulk operation failed', 'error');
}
},
exportCSV() {
if (!this.allData || !this.allData.users.length) return;
const rows = [['Email', 'Name', 'Balance']];
for (const u of this.allData.users) {
rows.push([u.email || '', u.name || u.username || '', u.tokenCredits || 0]);
}
const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `balances-page${this.currentPage}.csv`;
a.click();
URL.revokeObjectURL(url);
},
closeModal() {
document.getElementById('modal-overlay').classList.remove('active');
},
toast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 3000);
},
bindHistorySearch() {
const input = document.getElementById('history-search');
let timeout;
input.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(() => this.doHistorySearch(input.value), 300);
});
},
async doHistorySearch(query) {
const summary = document.getElementById('history-summary');
const results = document.getElementById('history-results');
const usersContainer = document.getElementById('history-users');
if (!query || query.length < 2) {
summary.style.display = 'none';
results.style.display = 'none';
usersContainer.style.display = 'none';
return;
}
// Check if it looks like a full email
if (query.includes('@')) {
await this.loadUserHistory(query);
} else {
// Search for matching users
const data = await this.api(`/history/search?q=${encodeURIComponent(query)}`);
if (!data) return;
summary.style.display = 'none';
results.style.display = 'none';
if (data.users.length === 0) {
usersContainer.style.display = 'block';
document.getElementById('history-users-count').textContent = 'No matches';
document.getElementById('history-users-tbody').innerHTML =
'<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px">No history found for this search</td></tr>';
return;
}
if (data.users.length === 1) {
await this.loadUserHistory(data.users[0].email);
return;
}
usersContainer.style.display = 'block';
document.getElementById('history-users-count').textContent = `${data.users.length} Users`;
document.getElementById('history-users-tbody').innerHTML = data.users.map(u => {
const lastDate = u.lastActivity
? new Date(u.lastActivity).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: '-';
return `
<tr>
<td class="email-cell">${this.esc(u.email)}</td>
<td>${u.totalRequests}</td>
<td class="balance-cell balance-positive">${u.totalAdded.toLocaleString()}</td>
<td style="color:var(--text-muted);font-size:12px">${lastDate}</td>
<td><button class="btn btn-sm btn-primary" data-history-email="${u.email.replace(/"/g, '&quot;')}">View</button></td>
</tr>
`;
}).join('');
}
},
async loadUserHistory(email) {
const data = await this.api(`/history?email=${encodeURIComponent(email)}`);
if (!data) return;
const summary = document.getElementById('history-summary');
const results = document.getElementById('history-results');
const usersContainer = document.getElementById('history-users');
usersContainer.style.display = 'none';
summary.style.display = 'block';
results.style.display = 'block';
document.getElementById('history-email').textContent = email;
document.getElementById('history-total').textContent = data.totalAdded.toLocaleString();
document.getElementById('history-count').textContent = data.entries.length;
document.getElementById('history-results-count').textContent = `${data.entries.length} Entries`;
const tbody = document.getElementById('history-tbody');
if (data.entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:32px">No history for this user</td></tr>';
return;
}
tbody.innerHTML = data.entries.map(h => {
const date = new Date(h.timestamp).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
const actionLabels = {
add: 'Top Up',
set: 'Set Balance',
approve: 'Request Approved',
bulk_add: 'Bulk Add',
bulk_set: 'Bulk Set',
};
const actionLabel = actionLabels[h.action] || h.action;
const actionClass = h.action === 'approve' ? 'status-approved' : h.action === 'set' || h.action === 'bulk_set' ? 'status-pending' : 'status-approved';
return `
<tr>
<td class="email-cell">${this.esc(h.email)}</td>
<td><span class="status-badge ${actionClass}">${actionLabel}</span></td>
<td class="balance-cell balance-positive">${h.amount ? h.amount.toLocaleString() : '-'}</td>
<td style="color:var(--text-muted)">${h.source || '-'}</td>
<td style="color:var(--text-muted)">${h.omgJobNumber || '-'}</td>
<td class="balance-cell">${h.balanceAfter != null ? h.balanceAfter.toLocaleString() : '-'}</td>
<td style="color:var(--text-muted);font-size:12px">${date}</td>
</tr>
`;
}).join('');
},
async updateRequestBadge() {
const data = await this.api('/admin/requests/count');
if (!data) return;
const badge = document.getElementById('requests-badge');
if (data.count > 0) {
badge.textContent = data.count;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
},
async loadRequests(filter = 'pending') {
document.querySelectorAll('[data-req-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.reqFilter === filter);
});
const param = filter === 'all' ? '' : `?status=${filter}`;
const data = await this.api(`/admin/requests${param}`);
if (!data) return;
const tbody = document.getElementById('requests-tbody');
document.getElementById('requests-count').textContent = `${data.requests.length} Request${data.requests.length !== 1 ? 's' : ''}`;
if (data.requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px">No requests</td></tr>';
return;
}
tbody.innerHTML = data.requests.map(r => this.requestRow(r)).join('');
},
requestRow(r) {
const date = new Date(r.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
const statusClass = `status-${r.status}`;
const statusText = r.status.charAt(0).toUpperCase() + r.status.slice(1);
const amountText = r.amountApproved ? ` (${(r.amountApproved / 1000000).toFixed(1)}M)` : '';
let actions = '';
if (r.status === 'pending') {
actions = `
<div class="actions-cell" style="flex-wrap:wrap">
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="5000000">5M</button>
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="10000000">10M</button>
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="20000000">20M</button>
<button class="btn btn-sm btn-primary" data-req-action="custom" data-reqid="${r.id}" data-email="${r.email.replace(/"/g, '&quot;')}">Custom</button>
<button class="btn btn-sm btn-red" data-req-action="reject" data-reqid="${r.id}">Reject</button>
</div>
`;
} else {
actions = `<span style="color:var(--text-muted);font-size:12px">${statusText}${amountText}</span>`;
}
return `
<tr>
<td class="email-cell">${this.esc(r.email)}</td>
<td>${this.esc(r.omgJobNumber)}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td style="color:var(--text-muted);font-size:12px">${date}</td>
<td>${actions}</td>
</tr>
`;
},
showCustomApproveModal(reqId, email) {
const modal = document.getElementById('modal');
modal.innerHTML = `
<h3>Custom Top Up</h3>
<div class="modal-subtitle">${this.esc(email)}</div>
<div class="modal-field">
<label>Amount to Add</label>
<input type="number" id="modal-amount" placeholder="e.g. 12000000" autofocus>
<div class="bulk-presets" style="margin-top:10px">
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=12000000">12M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=15000000">15M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=20000000">20M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=50000000">50M</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="app.submitCustomApprove('${reqId}')">Approve & Top Up</button>
</div>
`;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
},
async submitCustomApprove(reqId) {
const amount = parseInt(document.getElementById('modal-amount').value);
if (isNaN(amount) || amount <= 0) {
this.toast('Please enter a valid amount', 'error');
return;
}
this.closeModal();
await this.handleRequestAction(reqId, 'approve', amount);
},
async handleRequestAction(reqId, action, amount) {
if (action === 'custom') return;
if (action === 'approve') {
const result = await this.api(`/admin/requests/${reqId}/approve`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (result && result.success) {
this.toast(`Approved and added ${(amount / 1000000).toFixed(1)}M tokens`, 'success');
this.loadRequests('pending');
this.updateRequestBadge();
} else {
this.toast(result?.error || 'Failed to approve', 'error');
}
} else if (action === 'reject') {
const confirmed = confirm('Reject this request?');
if (!confirmed) return;
const result = await this.api(`/admin/requests/${reqId}/reject`, {
method: 'POST',
});
if (result && result.success) {
this.toast('Request rejected', 'info');
this.loadRequests('pending');
this.updateRequestBadge();
} else {
this.toast(result?.error || 'Failed to reject', 'error');
}
}
},
esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
};
document.addEventListener('DOMContentLoaded', () => app.init());