feat: add smirk toggle and more admin capabilities

This commit is contained in:
jwinterm
2026-02-02 08:42:39 -05:00
parent 022a175b83
commit 6cf3961da0
7 changed files with 261 additions and 8 deletions
+109 -1
View File
@@ -280,6 +280,7 @@
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="queue">Queue</button>
<button class="tab-btn" data-tab="payouts">Payouts</button>
<button class="tab-btn" data-tab="games">Games</button>
<button class="tab-btn" data-tab="users">Users</button>
@@ -293,6 +294,34 @@
</div>
</div>
<!-- Tab: Queue -->
<div id="tab-queue" class="tab-content">
<div class="table-container">
<div class="table-header">
<h3>Game Queue</h3>
<button onclick="refreshQueue()" style="background:#055; color:#0f0; border:1px solid #0f0; padding:4px 10px; cursor:pointer;">🔄 Refresh</button>
</div>
<p style="color:#888; margin: 10px 0; font-size:12px;">
Entries waiting more than 10 minutes are highlighted in orange. Remove stuck entries that will never confirm.
</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Server ID</th>
<th>Waiting</th>
<th>Payment ID</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueTable">
<tr><td colspan="6" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: Payouts -->
<div id="tab-payouts" class="tab-content">
<div class="table-container">
@@ -467,7 +496,8 @@
document.getElementById(`tab-${tab}`).classList.add('active');
// Load data for tab
if (tab === 'payouts') loadPayouts();
if (tab === 'queue') loadQueue();
else if (tab === 'payouts') loadPayouts();
else if (tab === 'games') loadGames();
else if (tab === 'users') loadUsers();
}
@@ -563,6 +593,84 @@
});
}
// Queue management functions
async function loadQueue() {
try {
const res = await fetch('/api/admin/queue', { headers: { 'X-Admin-Key': apiKey } });
const data = await res.json();
const tbody = document.getElementById('queueTable');
if (!data.queue?.length) {
tbody.innerHTML = '<tr><td colspan="6" class="loading" style="color:#0f0;">Queue is empty - no waiting players</td></tr>';
return;
}
tbody.innerHTML = data.queue.map(entry => {
const isStuck = entry.waitingSeconds > 600; // > 10 minutes
const rowStyle = isStuck ? 'background: rgba(255,100,0,0.2);' : '';
const statusText = entry.confirmed ? '✅ Confirmed' :
entry.requiresConfirmation ? '⏳ Awaiting Confirmation' : '🎮 Ready';
const formatWait = (sec) => {
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
};
return \`
<tr style="\${rowStyle}">
<td>\${entry.position}</td>
<td title="\${entry.serverId}">\${entry.serverId.substring(0, 12)}...</td>
<td style="\${isStuck ? 'color:#f80; font-weight:bold;' : ''}">\${formatWait(entry.waitingSeconds)}</td>
<td>\${entry.paymentId || '-'}</td>
<td>\${statusText}</td>
<td>
<button class="btn btn-danger" onclick="removeQueueEntry('\${entry.serverId}')" style="background:#500; color:#f88; border:1px solid #f55; padding:3px 8px; cursor:pointer;">
Remove
</button>
</td>
</tr>
\`;
}).join('');
} catch (e) {
console.error('Failed to load queue:', e);
document.getElementById('queueTable').innerHTML = '<tr><td colspan="6" class="loading" style="color:#f00;">Failed to load queue</td></tr>';
}
}
function refreshQueue() {
loadQueue();
}
async function removeQueueEntry(serverId) {
if (!confirm(\`Remove queue entry for \${serverId.substring(0, 12)}...? This will cancel their queue position.\`)) return;
const reason = prompt('Reason for removal (optional):') || 'Admin removal';
try {
const res = await fetch('/api/admin/queue/remove', {
method: 'POST',
headers: {
'X-Admin-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ serverId, reason })
});
const data = await res.json();
if (res.ok && data.success) {
alert('Queue entry removed successfully');
loadQueue();
loadDashboard(); // Refresh overview stats
} else {
alert('Error: ' + (data.message || 'Unknown error'));
}
} catch (e) {
alert('Failed to remove queue entry: ' + e.message);
}
}
async function loadPayouts() {
const status = document.getElementById('payoutStatusFilter').value;
const url = `/api/admin/stats/payouts?limit=50${status ? `&status=${status}` : ''}`;
+23 -4
View File
@@ -231,6 +231,18 @@ const SmirkAuth = {
* Should be called after DOM is ready and socket is connected
*/
init() {
// Check if server has Smirk disabled (e.g., for Monero stagenet)
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === false) {
console.log('Smirk integration disabled by server');
return;
}
// Prevent double-initialization
if (this._initialized) {
return;
}
this._initialized = true;
const container = $('#smirkButtonContainer');
if (!container.length) {
console.log('Smirk button container not found');
@@ -291,12 +303,19 @@ const SmirkAuth = {
}
};
// Auto-initialize when DOM is ready
// Auto-initialize when DOM is ready (with delay to allow game_mode_info to arrive first)
$(document).ready(function() {
// Wait a short moment for socket to connect before initializing
// Wait for game_mode_info to arrive first (typically within 2 seconds)
// SocketHandlers will call SmirkAuth.init() when it receives game_mode_info
// This fallback is for cases where game_mode_info doesn't include smirkEnabled
setTimeout(() => {
SmirkAuth.init();
}, 1000);
// Only auto-init if not already initialized by SocketHandlers
// and if SocketHandlers._smirkEnabled is not explicitly false
if (!SmirkAuth._initialized &&
(typeof SocketHandlers === 'undefined' || SocketHandlers._smirkEnabled !== false)) {
SmirkAuth.init();
}
}, 3000);
});
// Also re-check when socket connects (in case of reconnection)
+7 -1
View File
@@ -556,7 +556,13 @@ const SocketHandlers = {
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
SocketHandlers._smirkEnabled = data.smirkEnabled !== false; // Default to true if not specified
// Initialize Smirk auth if enabled and not already initialized
if (SocketHandlers._smirkEnabled && typeof SmirkAuth !== 'undefined' && !SmirkAuth._initialized) {
SmirkAuth.init();
}
if (typeof UI !== 'undefined' && UI.updateGameTitle) {
UI.updateGameTitle(data.cryptoType);
}
+14 -1
View File
@@ -354,7 +354,20 @@ HIGH_PENDING_THRESHOLD=10
ALERT_COOLDOWN_MS=3600000
# =============================================================================
# SECTION 14: DEVELOPMENT/DEBUG OPTIONS
# SECTION 14: SMIRK WALLET INTEGRATION
# =============================================================================
# Smirk is a browser extension wallet for Wownero/Monero.
# https://smirk.network
#
# Enable this for Wownero mainnet. Disable for Monero stagenet
# (Smirk doesn't support stagenet addresses).
# Enable Smirk wallet connection button (default: true)
# Set to false for Monero stagenet deployments
SMIRK_ENABLED=true
# =============================================================================
# SECTION 15: DEVELOPMENT/DEBUG OPTIONS
# =============================================================================
# These are for testing only. Don't use in production with real money!
+3 -1
View File
@@ -1194,7 +1194,9 @@ class GameModeManager {
enabled: paymentConfig.earlyEntry?.enabled ?? false,
allowInFreeMode: paymentConfig.earlyEntry?.allowInFreeMode ?? false,
allowInCreditsMode: paymentConfig.earlyEntry?.allowInCreditsMode ?? false
}
},
// Smirk wallet integration (disabled for Monero stagenet)
smirkEnabled: process.env.SMIRK_ENABLED !== 'false'
};
}
+85
View File
@@ -444,7 +444,11 @@ app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
// =============================================================================
// Smirk Wallet Authentication Endpoints
// =============================================================================
// Smirk is a browser extension wallet for Wownero/Monero.
// Disable for Monero stagenet deployments (Smirk doesn't support stagenet).
const smirkEnabled = process.env.SMIRK_ENABLED !== 'false';
if (smirkEnabled) {
/**
* POST /api/auth/smirk/challenge
* Generate a challenge for Smirk wallet signature verification
@@ -604,6 +608,8 @@ app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
});
}));
} // End of smirkEnabled block
// =============================================================================
// Admin API Endpoints (requires ADMIN_API_KEY)
// =============================================================================
@@ -911,6 +917,85 @@ app.post('/api/admin/alerts/test-email', adminAuth, asyncHandler(async (req, res
});
}));
// =============================================================================
// Admin Queue Management
// =============================================================================
/**
* GET /api/admin/queue
* Get current queue details for admin dashboard
* Returns full queue entries (not anonymized)
*/
app.get('/api/admin/queue', adminAuth, asyncHandler(async (req, res) => {
if (!socketHandlers?.queueManager) {
return res.status(503).json({
success: false,
message: 'Queue manager not available'
});
}
const queueDetails = socketHandlers.queueManager.getQueueDetailsForAdmin();
res.json({
success: true,
queue: queueDetails,
count: queueDetails.length
});
}));
/**
* POST /api/admin/queue/remove
* Remove a stuck queue entry by serverId
* Body: { serverId: string, reason?: string }
*/
app.post('/api/admin/queue/remove', adminAuth, asyncHandler(async (req, res) => {
const { serverId, reason } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
throw new ValidationError('Missing serverId', {
safeMessage: 'serverId is required to remove a queue entry.'
});
}
if (!socketHandlers?.queueManager) {
return res.status(503).json({
success: false,
message: 'Queue manager not available'
});
}
// Check if entry exists first
const isQueued = socketHandlers.queueManager.isPlayerQueued(serverId);
if (!isQueued) {
return res.status(404).json({
success: false,
message: `No queue entry found for serverId: ${serverId}`
});
}
// Remove the entry
const removed = socketHandlers.queueManager.removePlayer(serverId);
// Log admin action
console.log(`[Admin] Queue entry removed: serverId=${serverId}, reason="${reason || 'No reason provided'}", by admin at ${new Date().toISOString()}`);
// Optionally notify the user via socket (if still connected)
try {
io.to(serverId).emit('queue_cancelled', {
reason: 'admin_removed',
message: 'Your queue entry was removed by an administrator.'
});
} catch (notifyErr) {
// Ignore errors - user may be disconnected
}
res.json({
success: removed,
message: removed ? 'Queue entry removed successfully' : 'Failed to remove queue entry',
serverId: serverId
});
}));
/**
* GET /api/admin/users/search
* Search for users by socket ID prefix or payout address
+20
View File
@@ -288,6 +288,26 @@ class QueueManager {
waitingForConfirmation: entry.requiresConfirmation && !entry.confirmed
}));
}
/**
* Get full queue details for admin dashboard (not anonymized)
* @returns {Array} Full queue entries with all details
*/
getQueueDetailsForAdmin() {
const now = Date.now();
return this._waitingPlayers.map((entry, index) => ({
position: index + 1,
serverId: entry.serverId,
playerId: entry.serverId.substring(0, 6), // For display
entryTime: entry.entryTime ? new Date(entry.entryTime).toISOString() : null,
waitingSeconds: entry.entryTime ? Math.floor((now - entry.entryTime) / 1000) : 0,
paymentId: entry.paymentId || null,
userId: entry.userId || null,
requiresConfirmation: !!entry.requiresConfirmation,
confirmed: !!entry.confirmed,
isValuable: this._isValuableEntry(entry)
}));
}
}
module.exports = QueueManager;