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:
NW
2026-07-06 17:42:54 +01:00
parent 8d85776135
commit d4c476002c
1010 changed files with 256079 additions and 1625 deletions

326
src/admin/views/wallets.ejs Normal file
View 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"> &mdash; <%= 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 &mdash; 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 &amp; 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>