feat(admin): SmartAdmin template redesign + security hardening
- Migrated all admin views from inline JS string templates to EJS - Integrated SmartAdmin template with dark sidebar, fixed header, CSS grid - Added express-ejs-layouts for master layout wrapper - Security: - CSRF protection (double-submit cookie) - Rate limiting on /login (5/15min) - Token revocation via jti + globalLogoutTimestamp - Re-auth (reauth_token) for destructive endpoints - Settings whitelist (ALLOWED_KEYS) + removed process.exit - Seed phrases no longer rendered in HTML (CSV export only) - Multer fileFilter for image uploads + safe filename generation - SQL injection fix (currency column allowlist) - Global error handler + asyncHandler wrapper - New files: csrf.js, errorHandler.js, error.ejs, all EJS templates - SmartAdmin assets: CSS, icons, webfonts, plugins, scripts
This commit is contained in:
326
src/admin/views/wallets.ejs
Normal file
326
src/admin/views/wallets.ejs
Normal file
@@ -0,0 +1,326 @@
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content p-2">
|
||||
<input type="text" id="user-search" class="form-control form-control-sm mb-2" placeholder="Search by name or ID..." autocomplete="off">
|
||||
<div class="list-group list-group-flush custom-scroll" id="user-list">
|
||||
<% if (users.length === 0) { %>
|
||||
<p class="text-muted">No users</p>
|
||||
<% } else { %>
|
||||
<% users.forEach(function(u) { %>
|
||||
<% var isActive = selectedUser && u.id === selectedUser.id; %>
|
||||
<% var statusText = u.status === 0 ? '✅' : '❌'; %>
|
||||
<% var seedsParam = seedsUnlocked ? '&seeds=1' : ''; %>
|
||||
<a href="/wallets?user=<%= u.id %><%= seedsParam %>" class="dropdown-item <%= isActive ? 'active bg-primary-100' : '' %>" data-id="<%= u.id %>" data-name="<%= (u.username || '').toLowerCase() %>" data-tgid="<%= u.telegram_id %>">
|
||||
<span class="wallet-user-id">#<%= u.id %></span>
|
||||
<span class="wallet-user-name"><%= u.username || u.telegram_id %></span>
|
||||
<span class="wallet-user-meta"><%= statusText %> <%= u.wallet_count %>w</span>
|
||||
</a>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<% if (selectedUser) { %>
|
||||
<h2 class="mb-3"><%= selectedUser.username || 'User #' + selectedUser.id %>
|
||||
<span class="text-muted fw-normal fs-sm"> — <%= selectedUser.telegram_id %></span>
|
||||
</h2>
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Balances</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<p><strong>Main:</strong> $<%= Number(selectedUser.total_balance || 0).toFixed(2) %></p>
|
||||
<p><strong>Bonus:</strong> $<%= Number(selectedUser.bonus_balance || 0).toFixed(2) %></p>
|
||||
<p><strong>Available:</strong> $<%= Number((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2) %></p>
|
||||
<p><strong>Status:</strong> <span class="badge <%= selectedUser.status === 0 ? 'bg-success' : 'bg-danger' %>"><%= selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted' %></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Crypto Wallets (<%= wallets.length %>)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<table class="table table-striped table-bordered table-hover mb-0">
|
||||
<thead><tr><th>Type</th><th>Address</th><th>Balance</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
<% if (wallets.length === 0) { %>
|
||||
<tr><td colspan="4" class="text-muted">No wallets yet</td></tr>
|
||||
<% } else { %>
|
||||
<% wallets.forEach(function(w) { %>
|
||||
<tr>
|
||||
<td><strong><%= w.wallet_type %></strong></td>
|
||||
<td><code class="wallet-addr" data-addr="<%= w.address || '' %>" data-bs-toggle="tooltip" title="Click to copy"><%= w.address || '' %></code></td>
|
||||
<td><%= Number(w.balance || 0).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0') %></td>
|
||||
<td><%= w.created_at || '-' %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p class="text-muted">Select a user to view wallets</p>
|
||||
<% } %>
|
||||
|
||||
<% if (hasStats) { %>
|
||||
<h2 class="mb-3">Owner Summary</h2>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Total USD</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2">$<%= Number(stats.totalUsd).toFixed(2) %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalUsers %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Active Wallets</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalWallets %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Commission Due</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2">$<%= Number(stats.commissionDue).toFixed(2) %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Wallet Balances by Currency</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<table class="table table-sm table-striped table-bordered mb-0">
|
||||
<thead><tr><th>Coin</th><th>Wallets</th><th>Balance</th><th>USD Value</th></tr></thead>
|
||||
<tbody>
|
||||
<% Object.entries(stats.totals).forEach(function(entry) { %>
|
||||
<% var coin = entry[0]; var balance = entry[1]; %>
|
||||
<% if (balance <= 0 && !(stats.walletCounts[coin] > 0)) return; %>
|
||||
<tr>
|
||||
<td><strong><%= coin.toUpperCase() %></strong></td>
|
||||
<td><%= stats.walletCounts[coin] || 0 %></td>
|
||||
<td><%= Number(balance).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0') %></td>
|
||||
<td>$<%= Number(stats.usdValues[coin] || 0).toFixed(2) %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<tr class="table-active">
|
||||
<td><strong>Total</strong></td>
|
||||
<td><%= stats.totalWallets %></td>
|
||||
<td></td>
|
||||
<td><strong>$<%= Number(stats.totalUsd).toFixed(2) %></strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Commission <%= stats.commissionEnabled ? '' : '(Disabled)' %></h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
<tr><td>Rate</td><td><strong><%= stats.commissionRate %>%</strong> of total wallet balances</td></tr>
|
||||
<tr><td>Total Balances</td><td>$<%= Number(stats.totalUsd).toFixed(2) %></td></tr>
|
||||
<tr><td>Full Commission</td><td>$<%= Number(stats.currentCommission).toFixed(2) %></td></tr>
|
||||
<tr><td>Last Paid</td><td>$<%= Number(stats.lastPaidAmount).toFixed(2) %></td></tr>
|
||||
<tr class="table-warning"><td><strong>Due Now</strong></td><td><strong>$<%= Number(stats.commissionDue).toFixed(2) %></strong></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted mt-2">Commission is <%= stats.commissionRate %>% of total wallet balances. Each payment records a snapshot — if the shop continues running and balances grow, the difference becomes the next payment due.</p>
|
||||
|
||||
<form method="POST" action="/wallets/record-payment" class="row g-2 mt-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input name="paid_amount" type="number" step="0.01" class="form-control" placeholder="Amount paid (USD)" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input name="note" type="text" class="form-control" placeholder="Note (optional)">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">Record Payment</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-2">
|
||||
<strong>Pay to:</strong>
|
||||
<% Object.entries(stats.commissionWallets).forEach(function(entry) { %>
|
||||
<% if (!entry[1]) return; %>
|
||||
<div class="commission-wallet"><strong><%= entry[0] %>:</strong> <code class="wallet-addr" data-addr="<%= entry[1] %>" data-bs-toggle="tooltip" title="Click to copy"><%= entry[1] %></code></div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (stats.payments && stats.payments.length > 0) { %>
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Payment History</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<table class="table table-sm table-striped table-bordered mb-0">
|
||||
<thead><tr><th>Date</th><th>Total Balances</th><th>Commission @<%= stats.commissionRate %>%</th><th>Paid</th><th>Delta</th><th>Note</th></tr></thead>
|
||||
<tbody>
|
||||
<% stats.payments.forEach(function(p, idx) { %>
|
||||
<% var nextP = stats.payments[idx + 1]; %>
|
||||
<% var delta = p.commission_amount_usd - (nextP ? nextP.commission_amount_usd : 0); %>
|
||||
<tr>
|
||||
<td><%= p.created_at ? new Date(p.created_at).toLocaleString() : '-' %></td>
|
||||
<td>$<%= Number(p.total_balance_usd).toFixed(2) %></td>
|
||||
<td>$<%= Number(p.commission_amount_usd).toFixed(2) %></td>
|
||||
<td>$<%= Number(p.paid_amount_usd).toFixed(2) %></td>
|
||||
<td><%= delta > 0 ? '+' : '' %>$<%= Number(delta).toFixed(2) %></td>
|
||||
<td><%= p.note || '-' %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2>Seed Phrases & Wallet Access</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (seedsUnlocked) { %>
|
||||
<p class="text-muted mb-2">Seed phrases are unlocked. Download via CSV export.</p>
|
||||
<form method="POST" action="/wallets/export-seeds" class="mb-3">
|
||||
<button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<div class="alert alert-warning mb-2">
|
||||
<strong>🔒 Seed phrases are encrypted and locked until commission is paid.</strong>
|
||||
<% if (stats.commissionDue > 0) { %>
|
||||
<p class="mb-0 mt-2">Commission owed: <strong>$<%= Number(stats.commissionDue).toFixed(2) %></strong> (<%= stats.commissionRate %>% of current total balances minus last payment).</p>
|
||||
<p class="mb-0 mt-1">Record a payment above to unlock access.</p>
|
||||
<% } else { %>
|
||||
<p class="mb-0 mt-2">No wallet balances to calculate commission. Commission will be calculated once users deposit funds.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (stats.seedsPaid) { %>
|
||||
<a href="/wallets?seeds=1&user=<%= selectedUser ? selectedUser.id : '' %>" class="btn btn-danger">🔓 Unlock Seed Phrases</a>
|
||||
<% } else { %>
|
||||
<span class="btn btn-secondary disabled">🔒 Unlock requires commission payment</span>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var search = document.getElementById('user-search');
|
||||
var items = document.querySelectorAll('.wallet-user-item, .dropdown-item');
|
||||
search.addEventListener('input', function() {
|
||||
var q = this.value.toLowerCase().trim();
|
||||
items.forEach(function(item) {
|
||||
var name = (item.dataset.name || '').toLowerCase();
|
||||
var id = (item.dataset.id || '');
|
||||
var tgid = (item.dataset.tgid || '');
|
||||
var match = !q || name.indexOf(q) !== -1 || id.indexOf(q) !== -1 || tgid.indexOf(q) !== -1;
|
||||
item.style.display = match ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||||
var tooltipEls = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltipEls.forEach(function(el) {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target.closest('.wallet-addr');
|
||||
if (!el) return;
|
||||
var addr = el.dataset.addr;
|
||||
if (!addr) return;
|
||||
navigator.clipboard.writeText(addr).then(function() {
|
||||
var tooltip = bootstrap.Tooltip.getInstance(el);
|
||||
if (tooltip) {
|
||||
var originalTitle = tooltip._getTitle();
|
||||
tooltip.setContent({ '.tooltip-inner': 'Copied!' });
|
||||
setTimeout(function() {
|
||||
tooltip.setContent({ '.tooltip-inner': originalTitle });
|
||||
}, 1200);
|
||||
} else {
|
||||
el.classList.add('copied');
|
||||
setTimeout(function() { el.classList.remove('copied'); }, 1200);
|
||||
}
|
||||
}).catch(function() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = addr;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
el.classList.add('copied');
|
||||
setTimeout(function() { el.classList.remove('copied'); }, 1200);
|
||||
} catch(ex) {}
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user