<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsApp MCP Stream - Admin</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='30' fill='%2325D366'/%3E%3Cpath d='M19 45l2-7a16 16 0 1110 6l-7 1z' fill='white' opacity='0.9'/%3E%3Ccircle cx='27' cy='27' r='3' fill='%2325D366'/%3E%3Ccircle cx='37' cy='27' r='3' fill='%2325D366'/%3E%3Cpath d='M25 37c3 3 11 3 14 0' stroke='%2325D366' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<style>
:root {
--bg: #0f1518;
--bg-soft: #161d21;
--panel: #0f1518;
--panel-border: rgba(255, 255, 255, 0.08);
--text: #f5f7f8;
--muted: #9fb0ba;
--accent: #25d366;
--accent-2: #5ee3ff;
--warning: #ff6b6b;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
}
body {
font-family: "Space Grotesk", "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
margin: 0;
padding: 32px 18px 60px;
background: radial-gradient(1200px 900px at 20% -10%, #1c3c2f 0%, rgba(15, 21, 24, 0) 50%),
radial-gradient(900px 600px at 90% 10%, #163144 0%, rgba(15, 21, 24, 0) 50%),
var(--bg);
color: var(--text);
}
.container {
max-width: 980px;
margin: 0 auto;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
border-radius: 18px;
padding: 28px;
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
}
h1 {
margin: 0;
font-weight: 600;
font-size: 28px;
letter-spacing: -0.02em;
}
.subtitle {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
}
.header {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
align-items: center;
margin-bottom: 22px;
}
.header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.status {
padding: 10px 16px;
border-radius: 999px;
font-weight: 600;
font-size: 13px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.06);
}
.status.authenticated {
background: rgba(37, 211, 102, 0.15);
color: #b3f3c6;
border-color: rgba(37, 211, 102, 0.3);
}
.status.not-authenticated {
background: rgba(255, 107, 107, 0.15);
color: #ffd1d1;
border-color: rgba(255, 107, 107, 0.35);
}
.qr-container {
text-align: center;
margin: 30px 0;
padding: 18px;
border-radius: 14px;
border: 1px dashed rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.15);
}
.qr-container h3 {
margin: 0 0 8px;
font-size: 16px;
}
.qr-container p {
margin: 6px 0;
color: var(--muted);
font-size: 13px;
}
#qrImage {
max-width: 300px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 10px;
background: #ffffff;
}
button {
background: linear-gradient(135deg, #25d366, #17b3a3);
color: white;
border: none;
padding: 11px 18px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
box-shadow: 0 8px 16px rgba(37, 211, 102, 0.22);
}
button:hover {
transform: translateY(-1px);
}
button:disabled {
background: #3b4a50;
color: #93a1a7;
box-shadow: none;
cursor: not-allowed;
}
button.logout {
background: linear-gradient(135deg, #ff6b6b, #d6455d);
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.25);
}
button.logout:hover {
transform: translateY(-1px);
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.log {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
margin-top: 18px;
max-height: 240px;
overflow-y: auto;
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
color: #d8e1e7;
}
.log-entry {
padding: 5px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.log-entry:last-child {
border-bottom: none;
}
.timestamp {
color: #7f94a0;
margin-right: 10px;
}
.info {
color: #7ed6ff;
}
.success {
color: #84f6b0;
}
.error {
color: #ff8b8b;
}
.hidden {
display: none;
}
.settings {
margin-top: 16px;
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(0, 0, 0, 0.25);
}
.settings label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: #cfe1ea;
}
.settings input[type="text"],
.settings input[type="password"] {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
font-size: 14px;
background: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.settings .row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tabs {
display: flex;
gap: 8px;
margin: 20px 0 10px;
flex-wrap: wrap;
}
.tab-btn {
background: rgba(255, 255, 255, 0.06);
color: #d2dde3;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 8px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
}
.tab-btn.active {
background: linear-gradient(135deg, #25d366, #17b3a3);
color: #fff;
border-color: transparent;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px 18px;
}
.settings .row.stretch {
justify-content: space-between;
}
.hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-top: 6px;
}
@media (max-width: 720px) {
.controls {
flex-direction: column;
}
button {
width: 100%;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<h1>WhatsApp MCP Stream</h1>
<div class="subtitle">Admin console for sessions, media, and exports</div>
</div>
<div id="statusContainer" class="status not-authenticated">
Checking authentication status...
</div>
</div>
<div id="qrContainer" class="qr-container hidden">
<h3>Scan QR Code with WhatsApp</h3>
<p>Open WhatsApp on your phone → Settings → Linked Devices → Link a Device</p>
<img id="qrImage" alt="QR Code">
<p><small>QR code will automatically update when needed</small></p>
</div>
<div class="controls">
<button id="getQrBtn" onclick="getQrCode()">
<span id="qrLoading" class="loading hidden"></span>
Get QR Code
</button>
<button id="checkStatusBtn" onclick="checkStatus()">
<span id="statusLoading" class="loading hidden"></span>
Check Status
</button>
<button id="forceResyncBtn" onclick="forceResync()">
<span id="resyncLoading" class="loading hidden"></span>
Force Resync
</button>
<button id="restartWaBtn" onclick="restartWa()">
<span id="restartLoading" class="loading hidden"></span>
Restart WhatsApp
</button>
<button id="logoutBtn" class="logout" onclick="logout()" disabled>
<span id="logoutLoading" class="loading hidden"></span>
Logout WhatsApp
</button>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="settingsTab" onclick="openTab('settingsTab', this)">Settings</button>
<button class="tab-btn" data-tab="exportTab" onclick="openTab('exportTab', this)">Export</button>
<button class="tab-btn" data-tab="statusTab" onclick="openTab('statusTab', this)">Status</button>
</div>
<div id="settingsTab" class="tab-panel active">
<div class="settings">
<div class="panel-title">Settings</div>
<div class="settings-grid">
<div class="field">
<label for="mediaBaseUrl">Media Public Base URL</label>
<input id="mediaBaseUrl" type="text" placeholder="http://192.168.10.32:3003">
</div>
<div class="field">
<label for="uploadMaxMb">Upload Max (MB)</label>
<input id="uploadMaxMb" type="text" placeholder="50">
</div>
<div class="field">
<label>Upload Controls</label>
<div class="row">
<label style="display:flex; align-items:center; gap:8px;">
<input id="uploadEnabled" type="checkbox" checked>
Upload enabled
</label>
<label style="display:flex; align-items:center; gap:8px;">
<input id="requireUploadToken" type="checkbox">
Require upload token
</label>
</div>
</div>
<div class="field">
<label for="maxFilesPerUpload">Max files per upload</label>
<input id="maxFilesPerUpload" type="text" placeholder="1">
</div>
<div class="field">
<label for="uploadToken">Upload Token</label>
<input id="uploadToken" type="password" placeholder="set a token">
</div>
<div class="field">
<label>Auto-download</label>
<div class="row">
<label style="display:flex; align-items:center; gap:8px;">
<input id="autoDownloadMedia" type="checkbox">
Auto-download media
</label>
</div>
</div>
<div class="field">
<label for="autoDownloadMaxMb">Auto-download max size (MB)</label>
<input id="autoDownloadMaxMb" type="text" placeholder="50">
<div class="hint">0 = unlimited. Leave empty to keep the current value.</div>
</div>
<div class="field">
<label>Contact Resolution</label>
<div class="row">
<label style="display:flex; align-items:center; gap:8px;">
<input id="autoSelectContact" type="checkbox">
Auto-select best contact match (only when a single match exists)
</label>
</div>
</div>
</div>
<div class="row stretch" style="margin-top: 16px;">
<span id="settingsStatus"></span>
<button id="saveSettingsBtn" onclick="saveSettings()">Save Settings</button>
</div>
</div>
</div>
<div id="exportTab" class="tab-panel">
<div class="settings">
<div class="panel-title">Export Chat</div>
<label for="exportJid">Chat JID</label>
<input id="exportJid" type="text" placeholder="123456789@s.whatsapp.net or 123456789-12345@g.us">
<div class="row">
<label style="display:flex; align-items:center; gap:8px;">
<input id="exportIncludeMedia" type="checkbox">
Include downloaded media
</label>
<button id="exportBtn" onclick="exportChat()">Export</button>
</div>
</div>
</div>
<div id="statusTab" class="tab-panel">
<div class="settings">
<div class="panel-title">Status</div>
<div id="statusSnapshot">No status loaded yet.</div>
</div>
</div>
<div class="log">
<h4>Activity Log</h4>
<div id="logEntries"></div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let isAuthenticated = false;
let isReady = false;
function logMessage(message, type = 'info') {
const logEntries = document.getElementById('logEntries');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
const timestamp = new Date().toLocaleTimeString();
entry.innerHTML = `<span class="timestamp">[${timestamp}]</span> <span class="${type}">${message}</span>`;
logEntries.prepend(entry);
}
function openTab(tabId, button) {
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
}
function formatTs(ts) {
if (!ts) return 'n/a';
try {
return new Date(ts).toLocaleString();
} catch (_e) {
return 'n/a';
}
}
function updateStatus(authStatus, readyStatus = false) {
const statusContainer = document.getElementById('statusContainer');
const qrContainer = document.getElementById('qrContainer');
const logoutBtn = document.getElementById('logoutBtn');
isAuthenticated = authStatus;
isReady = readyStatus;
if (authStatus) {
statusContainer.className = 'status authenticated';
statusContainer.innerHTML = isReady
? '✅ WhatsApp Authenticated & Connected'
: '🕒 Authenticated, initializing...';
qrContainer.classList.add('hidden');
logoutBtn.disabled = false;
} else {
statusContainer.className = 'status not-authenticated';
statusContainer.innerHTML = '❌ Not Authenticated. Please scan QR code.';
qrContainer.classList.remove('hidden');
logoutBtn.disabled = true;
}
}
async function apiRequest(endpoint, method = 'GET', data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`${API_BASE}${endpoint}`, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
logMessage(`API Error: ${error.message}`, 'error');
throw error;
}
}
async function checkStatus() {
const loading = document.getElementById('statusLoading');
loading.classList.remove('hidden');
try {
const data = await apiRequest('/api/status');
updateStatus(data.authenticated, data.ready);
logMessage(
`Status: ${data.authenticated ? 'Authenticated' : 'Not authenticated'}${data.ready ? ' (ready)' : ''}`,
'success'
);
logMessage(
`Sync: chats=${data.chatCount ?? 0}, messages=${data.messageCount ?? 0}, ` +
`history=${formatTs(data.lastHistorySyncAt)}, ` +
`chats=${formatTs(data.lastChatsSyncAt)}, msgs=${formatTs(data.lastMessagesSyncAt)}` +
`${data.warmupInProgress ? ' (warmup...)' : ''}`,
'info'
);
logMessage(
`DB: chats=${data.dbChats ?? 0}, messages=${data.dbMessages ?? 0}, media=${data.dbMedia ?? 0}`,
'info'
);
const statusSnapshot = document.getElementById('statusSnapshot');
statusSnapshot.textContent = JSON.stringify(data, null, 2);
} catch (error) {
logMessage('Failed to check status', 'error');
} finally {
loading.classList.add('hidden');
}
}
async function getQrCode() {
const loading = document.getElementById('qrLoading');
const qrImage = document.getElementById('qrImage');
loading.classList.remove('hidden');
try {
const response = await fetch(`${API_BASE}/api/qr`);
if (response.status === 204) {
logMessage('QR not available (client initializing or already authenticated)', 'info');
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
qrImage.src = imageUrl;
document.getElementById('qrContainer').classList.remove('hidden');
logMessage('QR code loaded', 'success');
} catch (error) {
logMessage('Failed to get QR code', 'error');
} finally {
loading.classList.add('hidden');
}
}
async function loadSettings() {
try {
const data = await apiRequest('/api/settings');
document.getElementById('mediaBaseUrl').value = data.media_public_base_url || '';
document.getElementById('uploadMaxMb').value = data.upload_max_mb ?? 50;
document.getElementById('uploadEnabled').checked = data.upload_enabled !== false;
document.getElementById('requireUploadToken').checked = Boolean(data.require_upload_token);
document.getElementById('maxFilesPerUpload').value = data.max_files_per_upload ?? 1;
document.getElementById('uploadToken').value = data.upload_token || '';
document.getElementById('autoDownloadMedia').checked = Boolean(data.auto_download_media);
if (data.auto_download_max_mb === undefined || data.auto_download_max_mb === null) {
document.getElementById('autoDownloadMaxMb').value = '';
} else {
document.getElementById('autoDownloadMaxMb').value = String(data.auto_download_max_mb);
}
document.getElementById('autoSelectContact').checked = Boolean(data.auto_select_contact);
} catch (error) {
logMessage('Failed to load settings', 'error');
}
}
async function saveSettings() {
const status = document.getElementById('settingsStatus');
status.textContent = 'Saving...';
try {
const mediaBaseUrl = document.getElementById('mediaBaseUrl').value || '';
const uploadMaxMbRaw = document.getElementById('uploadMaxMb').value || '50';
const uploadMaxMb = Number(uploadMaxMbRaw);
const uploadEnabled = document.getElementById('uploadEnabled').checked;
const requireUploadToken = document.getElementById('requireUploadToken').checked;
const maxFilesPerUploadRaw = document.getElementById('maxFilesPerUpload').value || '1';
const maxFilesPerUpload = Number(maxFilesPerUploadRaw);
const uploadToken = document.getElementById('uploadToken').value || '';
const autoDownloadMedia = document.getElementById('autoDownloadMedia').checked;
const autoDownloadMaxMbRaw = document.getElementById('autoDownloadMaxMb').value.trim();
const autoDownloadMaxMb = autoDownloadMaxMbRaw === '' ? null : Number(autoDownloadMaxMbRaw);
const autoSelectContact = document.getElementById('autoSelectContact').checked;
await apiRequest('/api/settings', 'POST', {
media_public_base_url: mediaBaseUrl,
upload_max_mb: Number.isFinite(uploadMaxMb) ? uploadMaxMb : 50,
upload_enabled: uploadEnabled,
require_upload_token: requireUploadToken,
max_files_per_upload: Number.isFinite(maxFilesPerUpload) ? maxFilesPerUpload : 1,
upload_token: uploadToken,
auto_download_media: autoDownloadMedia,
auto_download_max_mb: Number.isFinite(autoDownloadMaxMb) ? autoDownloadMaxMb : undefined,
auto_select_contact: autoSelectContact
});
status.textContent = 'Saved';
logMessage('Settings updated', 'success');
} catch (error) {
status.textContent = 'Save failed';
logMessage('Failed to save settings', 'error');
}
}
async function logout() {
if (!confirm('Are you sure you want to logout from WhatsApp? You will need to scan QR code again.')) {
return;
}
const loading = document.getElementById('logoutLoading');
loading.classList.remove('hidden');
try {
await apiRequest('/api/logout', 'POST');
updateStatus(false);
logMessage('Logged out successfully', 'success');
} catch (error) {
logMessage('Logout failed', 'error');
} finally {
loading.classList.add('hidden');
}
}
async function forceResync() {
if (!confirm('Force a full app-state resync? This may temporarily disconnect and reconnect.')) {
return;
}
const loading = document.getElementById('resyncLoading');
loading.classList.remove('hidden');
try {
await apiRequest('/api/force-resync', 'POST');
logMessage('Force resync requested', 'success');
setTimeout(checkStatus, 3000);
} catch (error) {
logMessage('Force resync failed', 'error');
} finally {
loading.classList.add('hidden');
}
}
async function restartWa() {
if (!confirm('Restart the WhatsApp client? This will temporarily disconnect and reconnect.')) {
return;
}
const loading = document.getElementById('restartLoading');
loading.classList.remove('hidden');
try {
await apiRequest('/api/restart-wa', 'POST');
logMessage('WhatsApp restart requested', 'success');
setTimeout(checkStatus, 3000);
} catch (error) {
logMessage('WhatsApp restart failed', 'error');
} finally {
loading.classList.add('hidden');
}
}
function exportChat() {
const jid = document.getElementById('exportJid').value.trim();
if (!jid) {
logMessage('Please enter a chat JID to export', 'error');
return;
}
const includeMedia = document.getElementById('exportIncludeMedia').checked;
const url = `${API_BASE}/api/export/chat/${encodeURIComponent(jid)}?include_media=${includeMedia ? 'true' : 'false'}`;
window.open(url, '_blank');
}
// Auto-refresh QR code when not authenticated
function startQrAutoRefresh() {
setInterval(async () => {
if (!isAuthenticated) {
try {
const response = await fetch(`${API_BASE}/api/qr`);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
document.getElementById('qrImage').src = imageUrl;
} else if (response.status === 204) {
// No QR yet
}
} catch (error) {
// Silent fail - QR might not be available yet
}
}
}, 10000); // Check every 10 seconds
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
logMessage('Admin panel loaded', 'info');
checkStatus();
loadSettings();
startQrAutoRefresh();
});
</script>
</body>
</html>