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:
NW
2026-07-09 16:40:15 +01:00
parent 83991f098b
commit d03c8419e5
8 changed files with 421 additions and 47 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>