feat(admin): super admin role, seed phrase viewer with QR code, CSRF disabled for Tor
- Add super admin role system (SUPER_ADMIN_SECRET env var) - requireSuperAuth middleware for sensitive routes - isSuperAdminWeb() helper for template access - Role badge in header (Super Admin / Admin) - Seed Viewer nav item visible only to super admins - Add seed phrase viewer with QR code generation - GET /wallets/seed/:walletId — JSON seed phrase (super admin only) - GET /wallets/seed-qr/:walletId — QR PNG image (super admin only) - Modal UI with reveal-on-click, 60s auto-hide countdown - Copy-to-clipboard and download QR as PNG - Audit logging for every seed phrase access - Disable CSRF completely for Tor/onion compatibility - csrfMiddleware no longer sets _csrf cookie - validateCsrf and validateCsrfFromBody are no-ops - res.locals.csrfToken set to empty string (prevents template errors) - .env.example: document SUPER_ADMIN_SECRET variable
This commit is contained in:
12
.env.example
12
.env.example
@@ -48,9 +48,11 @@ WG_ALLOWED_IPS=0.0.0.0/0,::/0
|
||||
SSH_HOST_IP=host.docker.internal
|
||||
# Имя контейнера магазина (для проброса админки через Tor)
|
||||
SHOP_CONTAINER=telegram_shop_prod
|
||||
# Порт админ-панели внутри контейнера магазина
|
||||
ADMIN_PORT=3001
|
||||
|
||||
# --- Gitea API (для CI/CD и пайплайна) ---
|
||||
GITEA_API_URL=https://git.softuniq.eu/api/v1
|
||||
GITEA_TOKEN=
|
||||
# --- Admin Panel ---
|
||||
ADMIN_SECRET=your_admin_token_here
|
||||
# SUPER_ADMIN_SECRET: If set to a different value than ADMIN_SECRET, users logging in
|
||||
# with this token get super_admin role (seed phrase access, commission management).
|
||||
# If not set or same as ADMIN_SECRET, all admins are super admins.
|
||||
SUPER_ADMIN_SECRET=
|
||||
ADMIN_PORT=3001
|
||||
@@ -12,9 +12,10 @@ const TOKEN_SECRET = envSecret || configSecret;
|
||||
const COOKIE_NAME = 'admin_token';
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Server-side session revocation (H7 fix)
|
||||
const revokedTokens = new Set(); // stores jti of revoked tokens
|
||||
let globalLogoutTimestamp = 0; // any token issued before this is invalid
|
||||
const SUPER_ADMIN_SECRET = process.env.SUPER_ADMIN_SECRET || undefined;
|
||||
|
||||
const revokedTokens = new Set();
|
||||
let globalLogoutTimestamp = 0;
|
||||
|
||||
// Rate limiting for login
|
||||
const loginAttempts = new Map();
|
||||
@@ -57,9 +58,40 @@ export function requireAuth(req, res, next) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
req.admin = payload;
|
||||
res.locals.adminRole = payload.role || 'admin';
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireSuperAuth(req, res, next) {
|
||||
const token = req.cookies?.[COOKIE_NAME];
|
||||
if (!token) {
|
||||
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (payload.role !== 'super_admin') {
|
||||
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
||||
return res.status(403).json({ error: 'Super admin access required' });
|
||||
}
|
||||
return res.status(403).send('Access denied. Super admin privileges required.');
|
||||
}
|
||||
req.admin = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function isSuperAdminWeb(req) {
|
||||
return req.admin && req.admin.role === 'super_admin';
|
||||
}
|
||||
|
||||
export function handleLogin(req, res) {
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
const now = Date.now();
|
||||
@@ -75,9 +107,13 @@ export function handleLogin(req, res) {
|
||||
|
||||
const { token } = req.body || {};
|
||||
const provided = Buffer.from(token || '', 'utf8');
|
||||
const expected = Buffer.from(TOKEN_SECRET, 'utf8');
|
||||
const adminExpected = Buffer.from(TOKEN_SECRET, 'utf8');
|
||||
const superAdminExpected = SUPER_ADMIN_SECRET ? Buffer.from(SUPER_ADMIN_SECRET, 'utf8') : null;
|
||||
|
||||
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||
const isAdminLogin = provided.length === adminExpected.length && crypto.timingSafeEqual(provided, adminExpected);
|
||||
const isSuperAdminLogin = superAdminExpected && provided.length === superAdminExpected.length && crypto.timingSafeEqual(provided, superAdminExpected);
|
||||
|
||||
if (!isAdminLogin && !isSuperAdminLogin) {
|
||||
if (!record || (now - record.firstAttempt) >= WINDOW_MS) {
|
||||
loginAttempts.set(clientIp, { count: 1, firstAttempt: now });
|
||||
} else {
|
||||
@@ -92,7 +128,9 @@ export function handleLogin(req, res) {
|
||||
|
||||
loginAttempts.delete(clientIp);
|
||||
|
||||
const { token: signed, jti } = signToken({ role: 'admin' });
|
||||
const role = isSuperAdminLogin ? 'super_admin' : 'admin';
|
||||
|
||||
const { token: signed, jti } = signToken({ role });
|
||||
res.cookie(COOKIE_NAME, signed, {
|
||||
httpOnly: true,
|
||||
sameSite: false,
|
||||
|
||||
@@ -1,37 +1,15 @@
|
||||
import crypto from 'crypto';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const CSRF_COOKIE = '_csrf';
|
||||
const CSRF_HEADER = 'x-csrf-token';
|
||||
|
||||
function generateToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function csrfMiddleware(req, res, next) {
|
||||
let token = req.cookies?.[CSRF_COOKIE];
|
||||
if (!token) {
|
||||
token = generateToken();
|
||||
res.cookie(CSRF_COOKIE, token, {
|
||||
httpOnly: false,
|
||||
sameSite: false,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
res.locals.csrfToken = token;
|
||||
res.locals.csrfToken = '';
|
||||
next();
|
||||
}
|
||||
|
||||
export function validateCsrf(req, res, next) {
|
||||
// CSRF checks disabled for Tor / onion zone compatibility
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token from req.body._csrf (for use inside multer handlers).
|
||||
* Returns true if valid, false otherwise. Sends 403 response on failure.
|
||||
* NOTE: Disabled for Tor / onion zone compatibility.
|
||||
*/
|
||||
export function validateCsrfFromBody(req, res) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import QRCode from 'qrcode';
|
||||
import db from '../../config/database.js';
|
||||
import config from '../../config/config.js';
|
||||
import WalletUtils from '../../utils/walletUtils.js';
|
||||
import { decrypt } from '../../utils/encryption.js';
|
||||
import { requireSuperAuth, isSuperAdminWeb } from '../auth.js';
|
||||
import { logAudit } from '../../services/auditService.js';
|
||||
import logger from '../../utils/logger.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -79,9 +82,8 @@ router.get('/', async (req, res) => {
|
||||
const seedsRequested = req.query.seeds === '1';
|
||||
const seedsPaid = lastPaidAmount >= currentCommission && currentCommission > 0;
|
||||
const seedsUnlocked = seedsRequested && seedsPaid;
|
||||
const isSuperAdmin = isSuperAdminWeb(req);
|
||||
|
||||
// Seed phrases are NEVER rendered inline in HTML (C2 fix).
|
||||
// They are only available via the authenticated CSV export endpoint.
|
||||
const stats = {
|
||||
...walletStats,
|
||||
commissionRate: config.COMMISSION_PERCENT,
|
||||
@@ -102,6 +104,7 @@ router.get('/', async (req, res) => {
|
||||
wallets,
|
||||
stats,
|
||||
seedsUnlocked,
|
||||
isSuperAdmin,
|
||||
hasStats: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -278,4 +281,99 @@ router.get('/refresh-balances/:userId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed/:walletId', requireSuperAuth, async (req, res) => {
|
||||
try {
|
||||
const walletId = parseInt(req.params.walletId, 10);
|
||||
if (isNaN(walletId) || walletId <= 0) {
|
||||
return res.status(400).json({ error: 'Invalid wallet ID' });
|
||||
}
|
||||
|
||||
const wallet = await db.getAsync(
|
||||
`SELECT w.*, u.telegram_id, u.username FROM crypto_wallets w JOIN users u ON w.user_id = u.id WHERE w.id = ?`,
|
||||
[walletId]
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return res.status(404).json({ error: 'Wallet not found' });
|
||||
}
|
||||
|
||||
let mnemonic = '';
|
||||
try {
|
||||
mnemonic = decrypt(wallet.mnemonic, wallet.user_id);
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Failed to decrypt mnemonic' });
|
||||
}
|
||||
|
||||
await logAudit('seed_phrase_viewed', req.admin?.role || 'unknown', {
|
||||
walletId: wallet.id,
|
||||
walletType: wallet.wallet_type,
|
||||
userId: wallet.user_id,
|
||||
userTelegramId: wallet.telegram_id,
|
||||
address: wallet.address,
|
||||
});
|
||||
|
||||
res.json({
|
||||
walletId: wallet.id,
|
||||
walletType: wallet.wallet_type,
|
||||
address: wallet.address,
|
||||
derivationPath: wallet.derivation_path,
|
||||
mnemonic,
|
||||
userId: wallet.user_id,
|
||||
username: wallet.username || wallet.telegram_id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error, walletId: req.params.walletId }, 'Error fetching seed phrase');
|
||||
res.status(500).json({ error: 'Failed to fetch seed phrase' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed-qr/:walletId', requireSuperAuth, async (req, res) => {
|
||||
try {
|
||||
const walletId = parseInt(req.params.walletId, 10);
|
||||
if (isNaN(walletId) || walletId <= 0) {
|
||||
return res.status(400).json({ error: 'Invalid wallet ID' });
|
||||
}
|
||||
|
||||
const wallet = await db.getAsync(
|
||||
`SELECT w.*, u.telegram_id, u.username FROM crypto_wallets w JOIN users u ON w.user_id = u.id WHERE w.id = ?`,
|
||||
[walletId]
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return res.status(404).json({ error: 'Wallet not found' });
|
||||
}
|
||||
|
||||
let mnemonic = '';
|
||||
try {
|
||||
mnemonic = decrypt(wallet.mnemonic, wallet.user_id);
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Failed to decrypt mnemonic' });
|
||||
}
|
||||
|
||||
await logAudit('seed_phrase_qr_viewed', req.admin?.role || 'unknown', {
|
||||
walletId: wallet.id,
|
||||
walletType: wallet.wallet_type,
|
||||
userId: wallet.user_id,
|
||||
userTelegramId: wallet.telegram_id,
|
||||
});
|
||||
|
||||
const qrDataUrl = await QRCode.toDataURL(mnemonic, {
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
|
||||
const base64Data = qrDataUrl.replace(/^data:image\/png;base64,/, '');
|
||||
const pngBuffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Content-Disposition', `inline; filename="seed-qr-wallet-${walletId}.png"`);
|
||||
res.send(pngBuffer);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, walletId: req.params.walletId }, 'Error generating seed QR');
|
||||
res.status(500).json({ error: 'Failed to generate QR code' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
|
||||
import ejsLayouts from 'express-ejs-layouts';
|
||||
import logger from '../utils/logger.js';
|
||||
import { requireAuth, handleLogin, handleLogout } from './auth.js';
|
||||
import { csrfMiddleware, validateCsrf, validateCsrfFromBody } from './csrf.js';
|
||||
import { csrfMiddleware } from './csrf.js';
|
||||
import { asyncHandler, globalErrorHandler } from './errorHandler.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import catalogRouter from './routes/catalog.js';
|
||||
@@ -48,13 +48,9 @@ app.get('/login', (req, res) => {
|
||||
app.post('/login', handleLogin);
|
||||
app.get('/logout', handleLogout);
|
||||
|
||||
// CSRF token for all authenticated views
|
||||
app.use(csrfMiddleware);
|
||||
app.use(requireAuth);
|
||||
|
||||
// Apply CSRF validation to all state-changing routes
|
||||
app.use(validateCsrf);
|
||||
|
||||
// Protect uploads behind auth with MIME hardening
|
||||
app.use('/uploads', express.static(join(__dirname, '..', '..', 'uploads'), {
|
||||
setHeaders: (res, path) => {
|
||||
|
||||
@@ -82,7 +82,13 @@
|
||||
</span>
|
||||
<div class="info-card-text">
|
||||
<div class="fs-lg text-truncate text-truncate-lg">Admin</div>
|
||||
<span class="text-truncate text-truncate-md opacity-80 fs-sm">admin@shop.local</span>
|
||||
<span class="text-truncate text-truncate-md opacity-80 fs-sm">
|
||||
<% if (typeof adminRole !== 'undefined' && adminRole === 'super_admin') { %>
|
||||
<span class="badge bg-danger me-1">Super Admin</span> admin@shop.local
|
||||
<% } else { %>
|
||||
admin@shop.local
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<% if (typeof adminRole !== 'undefined' && adminRole === 'super_admin') { %>
|
||||
<li class="nav-item">
|
||||
<a href="/wallets" title="Seed Viewer" data-filter-tags="seed phrase super admin" id="nav-seed-viewer">
|
||||
<svg class="sa-icon"><use href="/icons/sprite.svg#lock"></use></svg>
|
||||
<span class="nav-link-text" data-i18n>🔑 Seed Viewer</span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="/purchases" title="Purchases" data-filter-tags="purchases orders">
|
||||
<svg class="sa-icon"><use href="/icons/sprite.svg#shopping-cart"></use></svg>
|
||||
|
||||
@@ -61,10 +61,10 @@
|
||||
<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>
|
||||
<thead><tr><th>Type</th><th>Address</th><th>Balance</th><th>Created</th><% if (isSuperAdmin) { %><th>Seed</th><% } %></tr></thead>
|
||||
<tbody>
|
||||
<% if (wallets.length === 0) { %>
|
||||
<tr><td colspan="4" class="text-muted">No wallets yet</td></tr>
|
||||
<tr><td colspan="<%= isSuperAdmin ? 5 : 4 %>" class="text-muted">No wallets yet</td></tr>
|
||||
<% } else { %>
|
||||
<% wallets.forEach(function(w) { %>
|
||||
<tr>
|
||||
@@ -72,6 +72,9 @@
|
||||
<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>
|
||||
<% if (isSuperAdmin) { %>
|
||||
<td><button class="btn btn-sm btn-outline-danger seed-reveal-btn" data-wallet-id="<%= w.id %>" data-wallet-type="<%= w.wallet_type %>" data-user-id="<%= selectedUser.id %>">🔑 Seed</button></td>
|
||||
<% } %>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
@@ -258,7 +261,29 @@
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (seedsUnlocked) { %>
|
||||
<% if (isSuperAdmin) { %>
|
||||
<div class="alert alert-success mb-2">
|
||||
<strong>🔑 Super Admin Access:</strong> Click the <strong>Seed</strong> button next to any wallet above to view the seed phrase as text and QR code.
|
||||
</div>
|
||||
<p class="text-muted mb-2">You can also export all seed phrases as CSV.</p>
|
||||
<% if (seedsUnlocked) { %>
|
||||
<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>CSV Export Locked:</strong> Commission must be paid to unlock bulk CSV export.
|
||||
<% if (stats.commissionDue > 0) { %>
|
||||
<p class="mb-0 mt-1">Commission owed: <strong>$<%= Number(stats.commissionDue).toFixed(2) %></strong></p>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (stats.seedsPaid) { %>
|
||||
<a href="/wallets?seeds=1&user=<%= selectedUser ? selectedUser.id : '' %>" class="btn btn-warning">🔓 Unlock CSV Export</a>
|
||||
<% } else { %>
|
||||
<span class="btn btn-secondary disabled">🔒 CSV export requires commission payment</span>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% } else 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>
|
||||
@@ -286,7 +311,80 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<script>
|
||||
<!-- Seed Phrase Reveal Modal (Super Admin Only) -->
|
||||
<% if (isSuperAdmin) { %>
|
||||
<div class="modal fade" id="seedModal" tabindex="-1" aria-labelledby="seedModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="seedModalLabel">🔑 Seed Phrase — <span id="seedModalWalletType"></span></h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>⚠️ SECURITY WARNING:</strong> This seed phrase provides full access to the wallet funds. Never share it with anyone. This view is logged.
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Wallet Address</label>
|
||||
<div class="input-group">
|
||||
<code id="seedModalAddress" class="form-control bg-light font-monospace small" style="word-break:break-all;"></code>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard(document.getElementById('seedModalAddress').textContent)" title="Copy address">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Derivation Path</label>
|
||||
<div><code id="seedModalDerivation" class="text-muted"></code></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Seed Phrase</label>
|
||||
<div id="seedPhraseContainer" class="position-relative">
|
||||
<div id="seedPhraseMask" class="position-absolute w-100 h-100 d-flex align-items-center justify-content-center bg-dark text-white rounded" style="z-index:10;cursor:pointer;font-size:1.2rem;" onclick="revealSeedPhrase()">
|
||||
👁 Click to Reveal (<span id="seedCountdown">60</span>s)
|
||||
</div>
|
||||
<div id="seedPhraseText" class="p-3 bg-light rounded font-monospace" style="word-break:break-all;letter-spacing:1px;font-size:1.1rem;min-height:80px;user-select:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="copySeedPhrase()">📋 Copy Seed Phrase</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="hideSeedPhrase()">🔒 Hide Seed Phrase</button>
|
||||
</div>
|
||||
<div id="seedAutoHideNotice" class="text-muted small d-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Seed phrase will auto-hide in <strong id="seedAutoHideTimer">60</strong> seconds
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5 text-center">
|
||||
<label class="form-label fw-bold">QR Code</label>
|
||||
<div id="seedQrContainer" class="mb-3" style="max-width:300px;margin:0 auto;">
|
||||
<div class="p-3 bg-white rounded border">
|
||||
<img id="seedQrImage" src="" alt="Seed phrase QR code" class="img-fluid d-none" style="image-rendering:pixelated;">
|
||||
<div id="seedQrLoading" class="text-muted py-4">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<div class="mt-2">Loading QR...</div>
|
||||
</div>
|
||||
<div id="seedQrError" class="text-danger py-4 d-none">Failed to load QR code</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="downloadQr()">💾 Download QR as PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="text-muted small">
|
||||
<strong>User:</strong> <span id="seedModalUsername"></span> |
|
||||
<strong>Wallet ID:</strong> <span id="seedModalWalletId"></span> |
|
||||
<strong>Viewed at:</strong> <span id="seedModalTimestamp"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-danger" onclick="hideSeedPhrase(); bootstrap.Modal.getInstance(document.getElementById('seedModal')).hide();">🔒 Close & Hide Seed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
(function() {
|
||||
var search = document.getElementById('user-search');
|
||||
var items = document.querySelectorAll('#user-list .list-group-item');
|
||||
@@ -468,5 +566,154 @@
|
||||
if (currentUserId) {
|
||||
startPolling();
|
||||
}
|
||||
// --- Seed Phrase Reveal (Super Admin Only) ---
|
||||
var isSuperAdmin = <%= isSuperAdmin ? 'true' : 'false' %>;
|
||||
var seedRevealTimer = null;
|
||||
var seedAutoHideInterval = null;
|
||||
var currentSeedWalletId = null;
|
||||
|
||||
function openSeedModal(walletId, walletType, userId) {
|
||||
if (!isSuperAdmin) return;
|
||||
|
||||
currentSeedWalletId = walletId;
|
||||
document.getElementById('seedModalWalletType').textContent = walletType;
|
||||
document.getElementById('seedModalWalletId').textContent = walletId;
|
||||
document.getElementById('seedModalTimestamp').textContent = new Date().toLocaleString();
|
||||
|
||||
// Reset state
|
||||
document.getElementById('seedPhraseText').textContent = '';
|
||||
document.getElementById('seedPhraseMask').classList.remove('d-none');
|
||||
document.getElementById('seedPhraseMask').style.display = '';
|
||||
document.getElementById('seedModalAddress').textContent = '';
|
||||
document.getElementById('seedModalDerivation').textContent = '';
|
||||
document.getElementById('seedQrImage').classList.add('d-none');
|
||||
document.getElementById('seedQrLoading').classList.remove('d-none');
|
||||
document.getElementById('seedQrError').classList.add('d-none');
|
||||
document.getElementById('seedAutoHideNotice').classList.add('d-none');
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('seedModal'));
|
||||
modal.show();
|
||||
|
||||
// Fetch seed phrase
|
||||
fetch('/wallets/seed/' + walletId, {
|
||||
headers: { 'x-csrf-token': csrfToken }
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) throw new Error('Super admin access required');
|
||||
throw new Error('Failed to fetch seed phrase');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
document.getElementById('seedModalAddress').textContent = data.address || '';
|
||||
document.getElementById('seedModalDerivation').textContent = data.derivationPath || '';
|
||||
document.getElementById('seedModalUsername').textContent = data.username || 'User #' + data.userId;
|
||||
// Store seed phrase in memory but don't display yet
|
||||
window._currentSeedPhrase = data.mnemonic || '';
|
||||
// Load QR code
|
||||
var qrImg = document.getElementById('seedQrImage');
|
||||
qrImg.src = '/wallets/seed-qr/' + walletId + '?_t=' + Date.now();
|
||||
qrImg.onload = function() {
|
||||
document.getElementById('seedQrLoading').classList.add('d-none');
|
||||
qrImg.classList.remove('d-none');
|
||||
};
|
||||
qrImg.onerror = function() {
|
||||
document.getElementById('seedQrLoading').classList.add('d-none');
|
||||
document.getElementById('seedQrError').classList.remove('d-none');
|
||||
};
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('seedPhraseText').textContent = 'Error: ' + err.message;
|
||||
document.getElementById('seedPhraseMask').classList.add('d-none');
|
||||
document.getElementById('seedQrLoading').classList.add('d-none');
|
||||
document.getElementById('seedQrError').classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
function revealSeedPhrase() {
|
||||
if (!window._currentSeedPhrase) return;
|
||||
document.getElementById('seedPhraseText').textContent = window._currentSeedPhrase;
|
||||
document.getElementById('seedPhraseMask').style.display = 'none';
|
||||
document.getElementById('seedAutoHideNotice').classList.remove('d-none');
|
||||
|
||||
var secondsLeft = 60;
|
||||
document.getElementById('seedAutoHideTimer').textContent = secondsLeft;
|
||||
document.getElementById('seedCountdown').textContent = secondsLeft;
|
||||
|
||||
if (seedAutoHideInterval) clearInterval(seedAutoHideInterval);
|
||||
seedAutoHideInterval = setInterval(function() {
|
||||
secondsLeft--;
|
||||
document.getElementById('seedAutoHideTimer').textContent = secondsLeft;
|
||||
document.getElementById('seedCountdown').textContent = secondsLeft;
|
||||
if (secondsLeft <= 0) {
|
||||
hideSeedPhrase();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hideSeedPhrase() {
|
||||
document.getElementById('seedPhraseText').textContent = '••••••••••••••••••••••••••••••••••';
|
||||
document.getElementById('seedPhraseMask').style.display = '';
|
||||
document.getElementById('seedPhraseMask').classList.remove('d-none');
|
||||
document.getElementById('seedAutoHideNotice').classList.add('d-none');
|
||||
if (seedAutoHideInterval) {
|
||||
clearInterval(seedAutoHideInterval);
|
||||
seedAutoHideInterval = null;
|
||||
}
|
||||
window._currentSeedPhrase = null;
|
||||
}
|
||||
|
||||
function copySeedPhrase() {
|
||||
var text = window._currentSeedPhrase || document.getElementById('seedPhraseText').textContent;
|
||||
if (!text || text.indexOf('•') !== -1) return;
|
||||
copyToClipboard(text);
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
var btn = event.currentTarget || event.target;
|
||||
var original = btn.textContent;
|
||||
btn.textContent = '✅ Copied!';
|
||||
setTimeout(function() { btn.textContent = original; }, 1500);
|
||||
}).catch(function() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch(ex) {}
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadQr() {
|
||||
var img = document.getElementById('seedQrImage');
|
||||
if (!img.src || img.classList.contains('d-none')) return;
|
||||
var a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.download = 'seed-qr-wallet-' + (currentSeedWalletId || 'unknown') + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// Attach click handlers to seed reveal buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.seed-reveal-btn');
|
||||
if (!btn || !isSuperAdmin) return;
|
||||
e.preventDefault();
|
||||
openSeedModal(btn.dataset.walletId, btn.dataset.walletType, btn.dataset.userId);
|
||||
});
|
||||
|
||||
// Clean up on modal close
|
||||
document.getElementById('seedModal')?.addEventListener('hidden.bs.modal', function() {
|
||||
hideSeedPhrase();
|
||||
if (seedAutoHideInterval) {
|
||||
clearInterval(seedAutoHideInterval);
|
||||
seedAutoHideInterval = null;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user