Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"start": "node server.js",
"dev": "node server.js",
"deploy": "npm install && npm start",
"test": "node --test tests/frontend-integrity.test.js tests/browser-executable.test.js",
"test": "node --test tests/frontend-integrity.test.js tests/browser-executable.test.js tests/integration-status.test.js tests/settings-status-functional.test.js tests/configure-buttons.test.js",
"capture:readme": "node scripts/capture-readme-screenshot.js"
},
},
"keywords": [
"google-maps-scraper",
"data-extractor",
Expand Down
41 changes: 41 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2466,4 +2466,45 @@ textarea {
background: var(--accent-primary);
color: white;
transform: scale(1.1);
}


/* Integration Readiness Status */
.integration-status-list {
display: flex;
flex-direction: column;
gap: 10px;
}

.integration-badge {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
font-size: 0.9rem;
color: var(--text-secondary);
}

.integration-badge.configured {
border-color: var(--success);
color: var(--success);
}

.integration-badge.not-configured {
border-color: var(--warning);
color: var(--text-secondary);
}

.integration-badge a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 600;
font-size: 0.85rem;
}

.integration-badge a:hover {
text-decoration: underline;
}
40 changes: 38 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@
<h1>Dashboard</h1>
<p class="subtitle">Overview of your extraction campaigns</p>
</header>
<div class="card" id="integrationStatusCard" style="margin-bottom: 20px; padding: 16px;">
<h3 style="margin-bottom: 12px;">Integration Readiness</h3>
<div id="integrationStatusList" class="integration-status-list"></div>
</div>

<div class="stats-grid">
<div class="stat-card">
Expand Down Expand Up @@ -530,6 +534,8 @@ <h1>⚙️ Settings</h1>
SMTP</button>
<button class="tab-btn" onclick="switchSettingsTab('twilio', this)" style="flex:1;">📱
Twilio</button>
<button class="tab-btn" onclick="switchSettingsTab('ai', this)" style="flex:1;">
🤖 AI</button>
<button class="tab-btn" onclick="switchSettingsTab('apikeys', this)" style="flex:1;">🔑
API
Keys</button>
Expand Down Expand Up @@ -686,8 +692,38 @@ <h1>⚙️ Settings</h1>
style="padding: 8px 16px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-secondary); cursor: pointer;">🧪
Test Connection</button>
</div>


<!-- AI Settings -->
<div id="settingsTabAi" class="settings-tab-content hidden"
style="background: var(--bg-card); padding: 20px; border-radius: var(--radius-lg); border: 1px solid var(--border-color);">
<div style="background: linear-gradient(135deg, rgba(168,85,247,0.15), rgba(236,72,153,0.1)); padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 0.9rem;">
<strong>ℹ️ AI</strong> settings let you configure an AI provider for enrichment/analysis.
</div>
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px; display: block;">Provider</label>
<select id="settingsAiProvider" style="width: 100%; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);">
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
</select>
</div>
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px; display: block;">API Key</label>
<input type="password" id="settingsAiKey" placeholder="sk-..." style="width: 100%; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px;">
<div>
<label style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px; display: block;">Base URL (optional)</label>
<input type="text" id="settingsAiBaseUrl" placeholder="https://api.openai.com/v1" style="width: 100%; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);">
</div>
<div>
<label style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px; display: block;">Model</label>
<input type="text" id="settingsAiModel" placeholder="gpt-4o-mini" style="width: 100%; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);">
</div>
</div>
<div>
<label style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 4px; display: block;">Gemini Model (if using Gemini)</label>
<input type="text" id="settingsGeminiModel" placeholder="gemini-1.5-flash" style="width: 100%; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);">
</div>
</div>

<!-- API Keys Settings -->
<div id="settingsTabApikeys" class="settings-tab-content hidden"
Expand Down
53 changes: 52 additions & 1 deletion public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ async function exportTrafficLogs() {
showToast('Failed to export logs', 'error');
}
}
const INTEGRATION_LABELS = {
proxy: 'Proxy (Webshare)',
smtp: 'Email (SMTP)',
twilio: 'SMS (Twilio)',
zerobounce: 'Email Verification (ZeroBounce)',
ai: 'AI (OpenAI/Gemini)'
};

async function loadIntegrationStatus() {
try {
const res = await fetch('/api/settings/status');
const status = await res.json();
const container = document.getElementById('integrationStatusList');
container.innerHTML = Object.entries(status).map(([key, val]) => `
<div class="integration-badge ${val.configured ? 'configured' : 'not-configured'}">
<span>${val.configured ? '✅' : '⚠️'} ${INTEGRATION_LABELS[key] || key}</span>
${!val.configured ? `<a href="#" onclick="goToSettingsTab('${val.settingsTab}')">Configure</a>` : ''}
</div>
`).join('');
} catch (e) {
console.error('Failed to load integration status', e);
}
}
function goToSettingsTab(tab) {
const settingsNavLink = document.querySelector('.nav-item[data-page="settings"]');
if (settingsNavLink) settingsNavLink.click();

// tab switch thoda delay ke baad, taaki settings page pehle render ho jaaye
setTimeout(() => {
const btn = document.querySelector(`.tab-btn[onclick*="switchSettingsTab('${tab}'"]`);
if (btn) switchSettingsTab(tab, btn);
}, 50);
}

// call on dashboard load
loadIntegrationStatus();

function appendLog(consoleDiv, message) {
const div = document.createElement('div');
Expand Down Expand Up @@ -5204,7 +5240,15 @@ async function loadSettings() {
}
if (elements.settingsTwilioPhone) elements.settingsTwilioPhone.value = settings.twilio?.fromNumber || '';

// AI settings removed
// AI
if (elements.settingsAiProvider) elements.settingsAiProvider.value = settings.ai?.provider || 'openai';
if (elements.settingsAiBaseUrl) elements.settingsAiBaseUrl.value = settings.ai?.baseUrl || '';
if (elements.settingsAiModel) elements.settingsAiModel.value = settings.ai?.model || '';
if (elements.settingsGeminiModel) elements.settingsGeminiModel.value = settings.ai?.geminiModel || '';
if (elements.settingsAiKey) {
elements.settingsAiKey.value = settings.ai?.apiKey || '';
elements.settingsAiKey.dataset.masked = settings.ai?.apiKey?.includes('...') ? 'true' : 'false';
}

// API Keys
if (elements.settingsApiZerobounce) elements.settingsApiZerobounce.value = settings.apiKeys?.zerobounce || '';
Expand Down Expand Up @@ -5238,6 +5282,13 @@ async function saveSettings() {
authToken: (/^•+$/.test(elements.settingsTwilioToken?.value) || !elements.settingsTwilioToken?.value) ? '***' : elements.settingsTwilioToken.value,
fromNumber: elements.settingsTwilioPhone?.value || ''
},
ai: {
provider: elements.settingsAiProvider?.value || 'openai',
apiKey: elements.settingsAiKey?.value || '',
baseUrl: elements.settingsAiBaseUrl?.value || '',
model: elements.settingsAiModel?.value || '',
geminiModel: elements.settingsGeminiModel?.value || ''
},
apiKeys: {
zerobounce: elements.settingsApiZerobounce?.value || ''
}
Expand Down
30 changes: 29 additions & 1 deletion routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ router.get('/', (req, res) => {
if (masked.ai?.apiKey) masked.ai.apiKey = masked.ai.apiKey.substring(0, 8) + '...';
res.json(masked);
});
// GET /api/settings/status — readiness check, NEVER returns secret values
router.get('/status', (req, res) => {
const settings = loadSettings();

const status = {
proxy: {
configured: Boolean(settings.proxy?.webshareApiKey),
settingsTab: 'proxy'
},
smtp: {
configured: Boolean(settings.smtp?.host && settings.smtp?.user && settings.smtp?.pass),
settingsTab: 'smtp'
},
twilio: {
configured: Boolean(settings.twilio?.accountSid && settings.twilio?.authToken && settings.twilio?.fromNumber),
settingsTab: 'twilio'
},
zerobounce: {
configured: Boolean(settings.apiKeys?.zerobounce),
settingsTab: 'apikeys'
},
ai: {
configured: Boolean(settings.ai?.apiKey),
settingsTab: 'ai'
}
};

res.json(status);
});

// GET raw settings (for internal use)
router.get('/raw', (req, res) => {
Expand Down Expand Up @@ -264,5 +293,4 @@ router.post('/fetch-webshare-proxies', async (req, res) => {
res.json({ success: false, message: error.message });
}
});

module.exports = router;
84 changes: 84 additions & 0 deletions tests/configure-buttons.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const puppeteer = require('puppeteer');
const { withBrowserExecutable } = require('../services/browserExecutable');

const root = path.join(__dirname, '..');
const TEST_PORT = 3998;
const BASE_URL = `http://localhost:${TEST_PORT}`;

let serverProcess;
let browser;

function waitForServer(url, retries = 30) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(() => resolve())
.catch((err) => {
if (n <= 0) return reject(err);
setTimeout(() => attempt(n - 1), 300);
});
};
attempt(retries);
});
}

test.before(async () => {
serverProcess = spawn('node', ['server.js'], {
cwd: root,
env: { ...process.env, PORT: String(TEST_PORT) },
stdio: 'ignore'
});
await waitForServer(`${BASE_URL}/api/settings/status`);

browser = await puppeteer.launch(withBrowserExecutable({ headless: 'new' }));
});

test.after(async () => {
if (browser) await browser.close();
if (serverProcess) serverProcess.kill();
});

test('clicking the AI Configure link opens the AI settings tab (not API Keys)', async () => {
const page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'networkidle0' });

// wait for integration badges to render
await page.waitForSelector('.integration-badge');

// find and click the "Configure" link inside the AI badge
const clicked = await page.evaluate(() => {
const badges = Array.from(document.querySelectorAll('.integration-badge'));
const aiBadge = badges.find(b => b.textContent.includes('AI'));
if (!aiBadge) return false;
const link = aiBadge.querySelector('a');
if (!link) return false; // already configured, nothing to click
link.click();
return true;
});

assert.ok(clicked, 'expected an AI integration badge with a Configure link');

// goToSettingsTab has a 50ms setTimeout before switching tabs
await new Promise(r => setTimeout(r, 300));

const aiTabState = await page.evaluate(() => {
const tab = document.getElementById('settingsTabAi');
return tab ? { exists: true, hidden: tab.classList.contains('hidden') } : { exists: false };
});

assert.equal(aiTabState.exists, true, 'settingsTabAi element should exist in the DOM');
assert.equal(aiTabState.hidden, false, 'AI settings tab should be visible after clicking Configure');

// API Keys tab should NOT be the one shown
const apiKeysTabState = await page.evaluate(() => {
const tab = document.getElementById('settingsTabApikeys');
return tab ? tab.classList.contains('hidden') : true;
});
assert.equal(apiKeysTabState, true, 'API Keys tab should remain hidden when AI Configure is clicked');

await page.close();
});
22 changes: 22 additions & 0 deletions tests/integration-status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// tests/integration-status.test.js
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');

const root = path.join(__dirname, '..');

test('settings status route never returns raw secret values', () => {
const source = fs.readFileSync(path.join(root, 'routes', 'settings.js'), 'utf8');
const statusRoute = source.match(/router\.get\('\/status'[\s\S]*?\}\);/)[0];
// status block should only build booleans, never echo settings.smtp.pass / authToken / apiKey directly
assert.doesNotMatch(statusRoute, /res\.json\(settings\)/);
assert.match(statusRoute, /configured:\s*Boolean\(/);
});

test('dashboard has an integration status container with a render function', () => {
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'public', 'js', 'app.js'), 'utf8');
assert.match(html, /id="integrationStatusList"/);
assert.match(app, /async\s+function\s+loadIntegrationStatus\s*\(/);
});
Loading
Loading