feat: admin panel - Settings, Categories, Payment Wallets, Seed/Clear data, User Balances

- Settings page: bot token (masked), admin IDs, commission config, WireGuard status
- Categories page: CRUD with product count, delete guard
- Payment Wallets page: commission wallets display, toggle, percentage
- Users page: balance adjustment form (total_balance / bonus_balance) with audit log
- Seed & Reset page: seed demo data (5 users, 10 products, 5 wallets, 5 purchases)
  and clear all data button with confirmation
- Dashboard: flash messages for seed/clear success
- Fixed seed.js: use dynamic IDs instead of hardcoded to avoid FK violations
- Fixed seed.js: clear all tables before seeding to avoid UNIQUE constraints
This commit is contained in:
NW
2026-06-22 14:20:58 +01:00
parent 4657b1dfb5
commit 2012435370
15 changed files with 414 additions and 4 deletions

View File

@@ -206,6 +206,30 @@ textarea { min-height: 80px; resize: vertical; }
code { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width: 300px; }
.muted { color: var(--muted); font-size: 0.85rem; }
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.seed-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.seed-card.danger {
border-color: var(--danger);
border-width: 2px;
}
.seed-card h2 { margin-bottom: 0.5rem; }
.seed-card p { margin-bottom: 1rem; color: var(--muted); }
@media (max-width: 640px) {
.topnav { flex-direction: column; align-items: flex-start; }
.logout-btn { margin-left: 0; }

View File

@@ -0,0 +1,39 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderCategoryList } from '../views/categories.js';
const router = Router();
router.get('/', async (req, res) => {
const [categories, locations] = await Promise.all([
db.allAsync(`SELECT c.*, l.country, l.city, l.district,
(SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count
FROM categories c LEFT JOIN locations l ON c.location_id = l.id ORDER BY c.id`),
db.allAsync('SELECT id, country, city, district FROM locations ORDER BY country, city'),
]);
res.send(renderCategoryList(categories, locations));
});
router.post('/', async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]);
res.redirect('/categories');
});
router.post('/:id/update', async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
[name, location_id || null, req.params.id]);
res.redirect('/categories');
});
router.post('/:id/delete', async (req, res) => {
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products');
}
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
});
export default router;

View File

@@ -12,7 +12,11 @@ router.get('/', async (req, res) => {
db.allAsync('SELECT COALESCE(SUM(total_price), 0) as totalRevenue FROM purchases WHERE status = ?', ['completed']),
]);
res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue }));
let message = '';
if (req.query.seeded) message = 'Demo data seeded successfully!';
if (req.query.cleared) message = 'All data cleared successfully!';
res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue }, message));
});
export default router;

View File

@@ -0,0 +1,15 @@
import { Router } from 'express';
import config from '../../config/config.js';
import { renderPaymentWallets } from '../views/paymentWallets.js';
const router = Router();
router.get('/', (req, res) => {
res.send(renderPaymentWallets({
commissionEnabled: config.COMMISSION_ENABLED,
commissionPercent: config.COMMISSION_PERCENT,
wallets: config.COMMISSION_WALLETS,
}));
});
export default router;

117
src/admin/routes/seed.js Normal file
View File

@@ -0,0 +1,117 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderSeedPage } from '../views/seed.js';
const router = Router();
router.get('/', (req, res) => {
res.send(renderSeedPage());
});
router.post('/seed-demo', async (req, res) => {
try {
const delTables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'categories', 'users', 'locations'];
for (const t of delTables) {
await db.runAsync(`DELETE FROM ${t}`);
}
await db.runAsync("DELETE FROM sqlite_sequence WHERE name != '_meta'");
await db.runAsync(`INSERT INTO locations (country, city, district) VALUES
('Russia', 'Moscow', 'Center'),
('Russia', 'Saint Petersburg', 'North'),
('Germany', 'Berlin', 'Mitte')`);
const locs = await db.allAsync('SELECT id, city FROM locations');
const locMoscow = locs.find(l => l.city === 'Moscow').id;
const locSPb = locs.find(l => l.city === 'Saint Petersburg').id;
const locBerlin = locs.find(l => l.city === 'Berlin').id;
await db.runAsync('INSERT INTO categories (location_id, name) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)',
[locMoscow, 'Digital', locMoscow, 'Physical', locSPb, 'Premium', locSPb, 'VIP', locBerlin, 'Standard']);
const cats = await db.allAsync('SELECT id, name FROM categories');
const catDigital = cats.find(c => c.name === 'Digital').id;
const catPhysical = cats.find(c => c.name === 'Physical').id;
const catPremium = cats.find(c => c.name === 'Premium').id;
const catVIP = cats.find(c => c.name === 'VIP').id;
const catStandard = cats.find(c => c.name === 'Standard').id;
await db.runAsync(`INSERT INTO users (telegram_id, username, country, city, district, status, total_balance, bonus_balance) VALUES
('1001', 'alice', 'Russia', 'Moscow', 'Center', 1, 150.00, 25.00),
('1002', 'bob', 'Russia', 'Moscow', 'Center', 1, 85.50, 10.00),
('1003', 'charlie', 'Russia', 'Saint Petersburg', 'North', 1, 320.75, 50.00),
('1004', 'diana', 'Germany', 'Berlin', 'Mitte', 1, 45.00, 5.00),
('1005', 'evan', 'Germany', 'Berlin', 'Mitte', 0, 0.00, 0.00)`);
const users = await db.allAsync('SELECT id, username FROM users');
const uAlice = users.find(u => u.username === 'alice').id;
const uBob = users.find(u => u.username === 'bob').id;
const uCharlie = users.find(u => u.username === 'charlie').id;
const uDiana = users.find(u => u.username === 'diana').id;
await db.runAsync(`INSERT INTO products (location_id, category_id, name, description, price, quantity_in_stock, photo_url) VALUES
(?, ?, 'VPN Subscription 30d', 'Premium VPN access for 30 days', 9.99, 100, ''),
(?, ?, 'VPN Subscription 90d', 'Premium VPN access for 90 days', 24.99, 50, ''),
(?, ?, 'USB Drive 64GB', 'Encrypted USB drive', 29.99, 25, ''),
(?, ?, 'Premium Account 1 Year', 'Full premium access 12 months', 99.99, 10, ''),
(?, ?, 'VIP Access Lifetime', 'Lifetime VIP membership', 199.99, 5, ''),
(?, ?, 'Premium Account 6 Months', 'Premium access 6 months', 59.99, 20, ''),
(?, ?, 'Standard Package', 'Basic package with essentials', 14.99, 200, ''),
(?, ?, 'Security Toolkit', 'Digital security tools', 49.99, 30, ''),
(?, ?, 'VIP Express Pass', 'Priority VIP 3 months', 39.99, 15, ''),
(?, ?, 'Starter Kit', 'Beginner-friendly package', 4.99, 500, '')`,
[locMoscow, catDigital, locMoscow, catDigital, locMoscow, catPhysical,
locSPb, catPremium, locSPb, catVIP, locSPb, catPremium,
locBerlin, catStandard, locMoscow, catPhysical,
locSPb, catVIP, locBerlin, catStandard]);
const prods = await db.allAsync('SELECT id, name FROM products');
const pVPN30 = prods.find(p => p.name.includes('30d')).id;
const pUSB = prods.find(p => p.name.includes('USB')).id;
const pPrem1y = prods.find(p => p.name.includes('1 Year')).id;
const pStd = prods.find(p => p.name.includes('Standard')).id;
const pStarter = prods.find(p => p.name.includes('Starter')).id;
await db.runAsync(`INSERT INTO crypto_wallets (user_id, wallet_type, address, derivation_path, mnemonic, balance) VALUES
(?, 'BTC', 'bc1qexample1addr', 'm/44h/0h/0h/0/0', 'encrypted:1', 0.00543),
(?, 'ETH', '0xExampleEth1addr', 'm/44h/60h/0h/0/0', 'encrypted:2', 0.12500),
(?, 'BTC', 'bc1qexample2addr', 'm/44h/0h/0h/0/1', 'encrypted:3', 0.00210),
(?, 'LTC', 'ltc1qexample3addr', 'm/44h/2h/0h/0/0', 'encrypted:4', 1.50000),
(?, 'ETH', '0xExampleEth4addr', 'm/44h/60h/0h/0/1', 'encrypted:5', 0.50000)`,
[uAlice, uAlice, uBob, uCharlie, uDiana]);
await db.runAsync(`INSERT INTO purchases (user_id, product_id, wallet_type, tx_hash, quantity, total_price, status) VALUES
(?, ?, 'BTC', 'tx_a1b2c3d4', 1, 9.99, 'completed'),
(?, ?, 'ETH', 'tx_b2c3d4e5', 2, 59.98, 'completed'),
(?, ?, 'LTC', 'tx_c3d4e5f6', 1, 99.99, 'pending'),
(?, ?, 'BTC', 'tx_d4e5f6a7', 1, 14.99, 'completed'),
(?, ?, 'ETH', 'tx_e5f6a7b8', 3, 14.97, 'cancelled')`,
[uAlice, pVPN30, uBob, pUSB, uCharlie, pPrem1y, uAlice, pStd, uDiana, pStarter]);
await db.runAsync(`INSERT INTO transactions (user_id, wallet_type, tx_hash, amount) VALUES
(?, 'BTC', 'tx_f6a7b8c9', 0.01000),
(?, 'ETH', 'tx_a7b8c9d0', 0.50000),
(?, 'LTC', 'tx_b8c9d0e1', 2.00000)`,
[uAlice, uBob, uCharlie]);
res.redirect('/?seeded=1');
} catch (err) {
console.error('Seed error:', err.message);
res.redirect('/?seeded=0');
}
});
router.post('/clear-all', async (req, res) => {
try {
const tables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'categories', 'users', 'locations'];
for (const t of tables) {
await db.runAsync(`DELETE FROM ${t}`);
}
await db.runAsync("DELETE FROM sqlite_sequence WHERE name != '_meta'");
res.redirect('/?cleared=1');
} catch (err) {
console.error('Clear error:', err.message);
res.redirect('/?cleared=0');
}
});
export default router;

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import config from '../../config/config.js';
import { renderSettings } from '../views/settings.js';
const router = Router();
router.get('/', (req, res) => {
const data = {
botToken: config.BOT_TOKEN,
adminIds: config.ADMIN_IDS,
superAdminIds: config.SUPER_ADMIN_IDS,
commissionEnabled: config.COMMISSION_ENABLED,
commissionPercent: config.COMMISSION_PERCENT,
commissionWallets: config.COMMISSION_WALLETS,
wgEnabled: process.env.WG_ENABLED === 'true',
wgEndpoint: process.env.WG_ENDPOINT || '',
wgAddress: process.env.WG_ADDRESS || '',
};
res.send(renderSettings(data));
});
export default router;

View File

@@ -29,4 +29,20 @@ router.post('/:id/toggle-status', async (req, res) => {
res.redirect('/users');
});
router.post('/:id/adjust-balance', async (req, res) => {
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).send('User not found');
const amount = parseFloat(req.body.amount) || 0;
const currency = req.body.currency === 'bonus_balance' ? 'bonus_balance' : 'total_balance';
const newVal = (user[currency] || 0) + amount;
await db.runAsync(`UPDATE users SET ${currency} = ? WHERE id = ?`, [newVal, user.id]);
await db.runAsync(
'INSERT INTO audit_log (action, admin_id, details) VALUES (?, ?, ?)',
['balance_adjust', req.admin?.role || 'admin', JSON.stringify({
user_id: user.id, currency, amount, old: user[currency], new: newVal
})]
);
res.redirect(`/users/${user.id}`);
});
export default router;

View File

@@ -10,6 +10,10 @@ import productsRouter from './routes/products.js';
import walletsRouter from './routes/wallets.js';
import purchasesRouter from './routes/purchases.js';
import auditRouter from './routes/audit.js';
import settingsRouter from './routes/settings.js';
import categoriesRouter from './routes/categories.js';
import paymentWalletsRouter from './routes/paymentWallets.js';
import seedRouter from './routes/seed.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -37,6 +41,10 @@ app.use('/products', productsRouter);
app.use('/wallets', walletsRouter);
app.use('/purchases', purchasesRouter);
app.use('/audit', auditRouter);
app.use('/settings', settingsRouter);
app.use('/categories', categoriesRouter);
app.use('/payment-wallets', paymentWalletsRouter);
app.use('/seed', seedRouter);
export function startAdminPanel() {
const port = parseInt(process.env.ADMIN_PORT || '3001', 10);

View File

@@ -0,0 +1,50 @@
import { layout, flash } from './layout.js';
export function renderCategoryList(categories, locations) {
const locOptions = locations.map(l =>
`<option value="${l.id}">${escapeHtml(l.country)} / ${escapeHtml(l.city)} / ${escapeHtml(l.district || '')}</option>`
).join('');
const rows = categories.map(c => `<tr>
<td>${c.id}</td>
<td>${escapeHtml(c.name)}</td>
<td>${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'}</td>
<td>${c.product_count || 0}</td>
<td>
<form method="POST" action="/categories/${c.id}/update" style="display:inline" class="inline-form">
<input name="name" value="${escapeHtml(c.name)}" required>
<select name="location_id">
<option value="">-- None --</option>
${locations.map(l => `<option value="${l.id}" ${l.id === c.location_id ? 'selected' : ''}>${escapeHtml(l.country)} / ${escapeHtml(l.city)}</option>`).join('')}
</select>
<button class="btn-sm">Save</button>
</form>
<form method="POST" action="/categories/${c.id}/delete" style="display:inline" onsubmit="return confirm('Delete category?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const content = `
${flash('')}
<details class="form-section">
<summary>Add Category</summary>
<form method="POST" action="/categories" class="inline-form">
<input name="name" placeholder="Category name" required>
<select name="location_id">
<option value="">-- No location --</option>
${locOptions}
</select>
<button type="submit" class="btn">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="5">No categories</td></tr>'}</tbody>
</table>`;
return layout('Categories', content, 'categories');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -1,6 +1,6 @@
import { layout, statCard } from './layout.js';
import { layout, statCard, flash } from './layout.js';
export function renderDashboard(stats) {
export function renderDashboard(stats, message) {
const cards = [
statCard('Total Users', stats.totalUsers),
statCard('Total Products', stats.totalProducts),
@@ -8,6 +8,6 @@ export function renderDashboard(stats) {
statCard('Revenue', `$${(stats.totalRevenue || 0).toFixed(2)}`),
].join('');
const content = `<div class="stats-grid">${cards}</div>`;
const content = `${flash(message, 'info')}<div class="stats-grid">${cards}</div>`;
return layout('Dashboard', content, 'dashboard');
}

View File

@@ -6,6 +6,10 @@ export function layout(title, content, activeTab = '') {
{ href: '/wallets', label: 'Wallets', id: 'wallets' },
{ href: '/purchases', label: 'Purchases', id: 'purchases' },
{ href: '/audit', label: 'Audit Log', id: 'audit' },
{ href: '/settings', label: 'Settings', id: 'settings' },
{ href: '/categories', label: 'Categories', id: 'categories' },
{ href: '/payment-wallets', label: 'Payment Wallets', id: 'payment-wallets' },
{ href: '/seed', label: 'Seed & Reset', id: 'seed' },
];
const navHtml = nav.map(n =>

View File

@@ -0,0 +1,32 @@
import { layout } from './layout.js';
export function renderPaymentWallets(data) {
const walletRows = Object.entries(data.wallets).map(([type, addr]) =>
`<tr>
<td><strong>${type}</strong></td>
<td><code>${escapeHtml(addr || 'Not set')}</code></td>
</tr>`
).join('');
const content = `
<div class="detail-card">
<h2>Commission Status</h2>
<p><strong>Commission:</strong> <span class="badge badge-${data.commissionEnabled ? 'active' : 'banned'}">${data.commissionEnabled ? 'ON' : 'OFF'}</span></p>
<p><strong>Percentage:</strong> ${data.commissionPercent}%</p>
</div>
<div class="detail-card">
<h2>Commission Wallet Addresses</h2>
<p class="muted">These are the shop's payment wallets. Edit them in the <code>.env</code> file.</p>
<table>
<thead><tr><th>Currency</th><th>Address</th></tr></thead>
<tbody>${walletRows}</tbody>
</table>
</div>
`;
return layout('Payment Wallets', content, 'payment-wallets');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

23
src/admin/views/seed.js Normal file
View File

@@ -0,0 +1,23 @@
import { layout } from './layout.js';
export function renderSeedPage() {
const content = `
<div class="detail-card">
<h2>Seed Demo Data</h2>
<p>Insert sample data: 5 users, 3 locations, 5 categories, 10 products, 5 wallets, 5 purchases, 3 transactions.</p>
<form method="POST" action="/seed/seed-demo" onsubmit="return confirm('Insert demo data? This will add records to existing tables.')">
<button type="submit" class="btn btn-success">Seed Demo Data</button>
</form>
</div>
<div class="detail-card" style="border-color: var(--danger);">
<h2 style="color: var(--danger);">Clear All Data</h2>
<p>This will DELETE ALL records from: users, products, categories, purchases, transactions, crypto_wallets, locations, audit_log, user_states.</p>
<p><strong>The _meta table will be preserved.</strong></p>
<form method="POST" action="/seed/clear-all" onsubmit="return confirm('DELETE ALL DATA? This cannot be undone!')">
<button type="submit" class="btn btn-danger">Clear All Data</button>
</form>
</div>
`;
return layout('Seed & Reset', content, 'seed');
}

View File

@@ -0,0 +1,45 @@
import { layout } from './layout.js';
export function renderSettings(data) {
const maskToken = (t) => t ? t.slice(0, 10) + '***' : 'Not set';
const idList = (ids) => ids.length
? ids.map(id => `<code>${escapeHtml(id)}</code>`).join(', ')
: '<em>None</em>';
const walletRows = Object.entries(data.commissionWallets).map(([type, addr]) =>
`<tr><td><strong>${type}</strong></td><td><code>${escapeHtml(addr || 'Not set')}</code></td></tr>`
).join('');
const content = `
<div class="detail-card">
<h2>Bot Configuration</h2>
<p><strong>Bot Token:</strong> <code>${maskToken(data.botToken)}</code></p>
<p><strong>Admin IDs:</strong> ${idList(data.adminIds)}</p>
<p><strong>Super Admin IDs:</strong> ${idList(data.superAdminIds)}</p>
</div>
<div class="detail-card">
<h2>Commission Settings</h2>
<p><strong>Commission:</strong> <span class="badge badge-${data.commissionEnabled ? 'active' : 'banned'}">${data.commissionEnabled ? 'ON' : 'OFF'}</span></p>
<p><strong>Percentage:</strong> ${data.commissionPercent}%</p>
<h3>Commission Wallets</h3>
<table>
<thead><tr><th>Type</th><th>Address</th></tr></thead>
<tbody>${walletRows}</tbody>
</table>
</div>
<div class="detail-card">
<h2>WireGuard</h2>
<p><strong>Enabled:</strong> <span class="badge badge-${data.wgEnabled ? 'active' : 'banned'}">${data.wgEnabled ? 'ON' : 'OFF'}</span></p>
<p><strong>Endpoint:</strong> <code>${escapeHtml(data.wgEndpoint || 'Not set')}</code></p>
<p><strong>Address:</strong> <code>${escapeHtml(data.wgAddress || 'Not set')}</code></p>
</div>
`;
return layout('Settings', content, 'settings');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -41,6 +41,17 @@ export function renderUserDetail(user, purchases) {
<p><strong>Balance:</strong> $${(user.total_balance || 0).toFixed(2)}</p>
<p><strong>Bonus:</strong> $${(user.bonus_balance || 0).toFixed(2)}</p>
</div>
<details class="form-section">
<summary>Adjust Balance</summary>
<form method="POST" action="/users/${user.id}/adjust-balance" class="inline-form">
<input name="amount" type="number" step="0.01" placeholder="Amount (+/-)" required>
<select name="currency">
<option value="total_balance">Total Balance</option>
<option value="bonus_balance">Bonus Balance</option>
</select>
<button type="submit" class="btn">Adjust</button>
</form>
</details>
<h3>Recent Purchases</h3>
<table>
<thead><tr><th>ID</th><th>Product</th><th>Price</th><th>Date</th><th>Status</th></tr></thead>