From 2012435370f5bd2e21c9469ad6d159bc2755dc52 Mon Sep 17 00:00:00 2001 From: NW Date: Mon, 22 Jun 2026 14:20:58 +0100 Subject: [PATCH] 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 --- src/admin/public/style.css | 24 ++++++ src/admin/routes/categories.js | 39 ++++++++++ src/admin/routes/dashboard.js | 6 +- src/admin/routes/paymentWallets.js | 15 ++++ src/admin/routes/seed.js | 117 +++++++++++++++++++++++++++++ src/admin/routes/settings.js | 22 ++++++ src/admin/routes/users.js | 16 ++++ src/admin/server.js | 8 ++ src/admin/views/categories.js | 50 ++++++++++++ src/admin/views/dashboard.js | 6 +- src/admin/views/layout.js | 4 + src/admin/views/paymentWallets.js | 32 ++++++++ src/admin/views/seed.js | 23 ++++++ src/admin/views/settings.js | 45 +++++++++++ src/admin/views/users.js | 11 +++ 15 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 src/admin/routes/categories.js create mode 100644 src/admin/routes/paymentWallets.js create mode 100644 src/admin/routes/seed.js create mode 100644 src/admin/routes/settings.js create mode 100644 src/admin/views/categories.js create mode 100644 src/admin/views/paymentWallets.js create mode 100644 src/admin/views/seed.js create mode 100644 src/admin/views/settings.js diff --git a/src/admin/public/style.css b/src/admin/public/style.css index d6c1ae1..c9dfb75 100644 --- a/src/admin/public/style.css +++ b/src/admin/public/style.css @@ -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; } diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js new file mode 100644 index 0000000..eed4343 --- /dev/null +++ b/src/admin/routes/categories.js @@ -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; diff --git a/src/admin/routes/dashboard.js b/src/admin/routes/dashboard.js index 6b23990..63aae0f 100644 --- a/src/admin/routes/dashboard.js +++ b/src/admin/routes/dashboard.js @@ -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; diff --git a/src/admin/routes/paymentWallets.js b/src/admin/routes/paymentWallets.js new file mode 100644 index 0000000..ae77df4 --- /dev/null +++ b/src/admin/routes/paymentWallets.js @@ -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; diff --git a/src/admin/routes/seed.js b/src/admin/routes/seed.js new file mode 100644 index 0000000..f46a237 --- /dev/null +++ b/src/admin/routes/seed.js @@ -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; \ No newline at end of file diff --git a/src/admin/routes/settings.js b/src/admin/routes/settings.js new file mode 100644 index 0000000..4ecf7cd --- /dev/null +++ b/src/admin/routes/settings.js @@ -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; diff --git a/src/admin/routes/users.js b/src/admin/routes/users.js index 6b1bacb..2349cdb 100644 --- a/src/admin/routes/users.js +++ b/src/admin/routes/users.js @@ -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; diff --git a/src/admin/server.js b/src/admin/server.js index 2501731..78539ae 100644 --- a/src/admin/server.js +++ b/src/admin/server.js @@ -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); diff --git a/src/admin/views/categories.js b/src/admin/views/categories.js new file mode 100644 index 0000000..4a46014 --- /dev/null +++ b/src/admin/views/categories.js @@ -0,0 +1,50 @@ +import { layout, flash } from './layout.js'; + +export function renderCategoryList(categories, locations) { + const locOptions = locations.map(l => + `` + ).join(''); + + const rows = categories.map(c => ` + ${c.id} + ${escapeHtml(c.name)} + ${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'} + ${c.product_count || 0} + +
+ + + +
+
+ +
+ + `).join(''); + + const content = ` + ${flash('')} +
+ Add Category +
+ + + +
+
+ + + ${rows || ''} +
IDNameLocationProductsActions
No categories
`; + return layout('Categories', content, 'categories'); +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/admin/views/dashboard.js b/src/admin/views/dashboard.js index 5599b62..ba568ad 100644 --- a/src/admin/views/dashboard.js +++ b/src/admin/views/dashboard.js @@ -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 = `
${cards}
`; + const content = `${flash(message, 'info')}
${cards}
`; return layout('Dashboard', content, 'dashboard'); } diff --git a/src/admin/views/layout.js b/src/admin/views/layout.js index 91ae17f..da26b9d 100644 --- a/src/admin/views/layout.js +++ b/src/admin/views/layout.js @@ -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 => diff --git a/src/admin/views/paymentWallets.js b/src/admin/views/paymentWallets.js new file mode 100644 index 0000000..43b6b42 --- /dev/null +++ b/src/admin/views/paymentWallets.js @@ -0,0 +1,32 @@ +import { layout } from './layout.js'; + +export function renderPaymentWallets(data) { + const walletRows = Object.entries(data.wallets).map(([type, addr]) => + ` + ${type} + ${escapeHtml(addr || 'Not set')} + ` + ).join(''); + + const content = ` +
+

Commission Status

+

Commission: ${data.commissionEnabled ? 'ON' : 'OFF'}

+

Percentage: ${data.commissionPercent}%

+
+ +
+

Commission Wallet Addresses

+

These are the shop's payment wallets. Edit them in the .env file.

+ + + ${walletRows} +
CurrencyAddress
+
+ `; + return layout('Payment Wallets', content, 'payment-wallets'); +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/admin/views/seed.js b/src/admin/views/seed.js new file mode 100644 index 0000000..7a27d4f --- /dev/null +++ b/src/admin/views/seed.js @@ -0,0 +1,23 @@ +import { layout } from './layout.js'; + +export function renderSeedPage() { + const content = ` +
+

Seed Demo Data

+

Insert sample data: 5 users, 3 locations, 5 categories, 10 products, 5 wallets, 5 purchases, 3 transactions.

+
+ +
+
+ +
+

Clear All Data

+

This will DELETE ALL records from: users, products, categories, purchases, transactions, crypto_wallets, locations, audit_log, user_states.

+

The _meta table will be preserved.

+
+ +
+
+ `; + return layout('Seed & Reset', content, 'seed'); +} diff --git a/src/admin/views/settings.js b/src/admin/views/settings.js new file mode 100644 index 0000000..a3fbcf9 --- /dev/null +++ b/src/admin/views/settings.js @@ -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 => `${escapeHtml(id)}`).join(', ') + : 'None'; + + const walletRows = Object.entries(data.commissionWallets).map(([type, addr]) => + `${type}${escapeHtml(addr || 'Not set')}` + ).join(''); + + const content = ` +
+

Bot Configuration

+

Bot Token: ${maskToken(data.botToken)}

+

Admin IDs: ${idList(data.adminIds)}

+

Super Admin IDs: ${idList(data.superAdminIds)}

+
+ +
+

Commission Settings

+

Commission: ${data.commissionEnabled ? 'ON' : 'OFF'}

+

Percentage: ${data.commissionPercent}%

+

Commission Wallets

+ + + ${walletRows} +
TypeAddress
+
+ +
+

WireGuard

+

Enabled: ${data.wgEnabled ? 'ON' : 'OFF'}

+

Endpoint: ${escapeHtml(data.wgEndpoint || 'Not set')}

+

Address: ${escapeHtml(data.wgAddress || 'Not set')}

+
+ `; + return layout('Settings', content, 'settings'); +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/admin/views/users.js b/src/admin/views/users.js index 35c1258..61f9251 100644 --- a/src/admin/views/users.js +++ b/src/admin/views/users.js @@ -41,6 +41,17 @@ export function renderUserDetail(user, purchases) {

Balance: $${(user.total_balance || 0).toFixed(2)}

Bonus: $${(user.bonus_balance || 0).toFixed(2)}

+
+ Adjust Balance +
+ + + +
+

Recent Purchases

IDProductPriceDateStatus