+
+ โน๏ธ AI settings let you configure an AI provider for enrichment/analysis.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`
+
+ `).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');
@@ -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 || '';
@@ -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 || ''
}
diff --git a/routes/settings.js b/routes/settings.js
index d57dbbf..97f4968 100644
--- a/routes/settings.js
+++ b/routes/settings.js
@@ -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) => {
@@ -264,5 +293,4 @@ router.post('/fetch-webshare-proxies', async (req, res) => {
res.json({ success: false, message: error.message });
}
});
-
module.exports = router;
diff --git a/tests/configure-buttons.test.js b/tests/configure-buttons.test.js
new file mode 100644
index 0000000..50ba034
--- /dev/null
+++ b/tests/configure-buttons.test.js
@@ -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();
+});
\ No newline at end of file
diff --git a/tests/integration-status.test.js b/tests/integration-status.test.js
new file mode 100644
index 0000000..bc18bdd
--- /dev/null
+++ b/tests/integration-status.test.js
@@ -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*\(/);
+});
\ No newline at end of file
diff --git a/tests/settings-status-functional.test.js b/tests/settings-status-functional.test.js
new file mode 100644
index 0000000..b88e12f
--- /dev/null
+++ b/tests/settings-status-functional.test.js
@@ -0,0 +1,66 @@
+const path = require('node:path');
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { spawn } = require('node:child_process');
+
+const root = path.join(__dirname, '..');
+const TEST_PORT = 3999;
+const BASE_URL = `http://localhost:${TEST_PORT}`;
+
+let serverProcess;
+
+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`);
+});
+
+test.after(() => {
+ if (serverProcess) serverProcess.kill();
+});
+
+test('GET /api/settings/status returns readiness booleans for every integration', async () => {
+ const res = await fetch(`${BASE_URL}/api/settings/status`);
+ assert.equal(res.status, 200);
+
+ const body = await res.json();
+ for (const key of ['proxy', 'smtp', 'twilio', 'zerobounce', 'ai']) {
+ assert.ok(key in body, `status response should include "${key}"`);
+ assert.equal(typeof body[key].configured, 'boolean', `${key}.configured should be boolean`);
+ assert.equal(typeof body[key].settingsTab, 'string', `${key}.settingsTab should be a string`);
+ }
+});
+
+test('GET /api/settings/status maps the AI integration to the AI settings tab', async () => {
+ const res = await fetch(`${BASE_URL}/api/settings/status`);
+ const body = await res.json();
+ assert.equal(body.ai.settingsTab, 'ai');
+});
+
+test('GET /api/settings/status never leaks raw secret values', async () => {
+ const res = await fetch(`${BASE_URL}/api/settings/status`);
+ const text = await res.text();
+
+ // These are the example/test secrets that should never be committed or returned
+ assert.doesNotMatch(text, /test123/);
+ assert.doesNotMatch(text, /test@gmail\.com/);
+ // Status endpoint should only contain booleans + tab names, never long key-like strings
+ assert.doesNotMatch(text, /sk-[a-zA-Z0-9]{10,}/);
+});
\ No newline at end of file