Refactor WordPress integration service to use API key for connection testing

- Updated the `IntegrationService` to perform connection tests using only the API key, removing reliance on username and app password.
- Simplified health check logic and improved error messaging for better clarity.
- Added functionality to revoke API keys in the `WordPressIntegrationForm` component.
- Enhanced site settings page with a site selector and improved integration status display.
- Cleaned up unused code and improved overall structure for better maintainability.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-22 09:31:07 +00:00
parent 1a1214d93f
commit 029c66a0f1
12 changed files with 1337 additions and 456 deletions

View File

@@ -16,7 +16,8 @@ import {
AlertIcon,
DownloadIcon,
PlusIcon,
CopyIcon
CopyIcon,
TrashBinIcon
} from '../../icons';
import { Globe, Key, RefreshCw } from 'lucide-react';
@@ -40,15 +41,6 @@ export default function WordPressIntegrationForm({
const [generatingKey, setGeneratingKey] = useState(false);
const [apiKey, setApiKey] = useState<string>('');
const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [isActive, setIsActive] = useState(integration?.is_active ?? true);
const [syncEnabled, setSyncEnabled] = useState(integration?.sync_enabled ?? true);
useEffect(() => {
if (integration) {
setIsActive(integration.is_active ?? true);
setSyncEnabled(integration.sync_enabled ?? true);
}
}, [integration]);
// Load API key from site settings on mount
useEffect(() => {
@@ -121,6 +113,40 @@ export default function WordPressIntegrationForm({
}
};
const handleRevokeApiKey = async () => {
if (!confirm('Are you sure you want to revoke the API key? Your WordPress plugin will stop working until you generate a new key.')) {
return;
}
try {
setGeneratingKey(true);
// Clear API key from site settings by setting it to empty string
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
method: 'PATCH',
body: JSON.stringify({ wp_api_key: '' }),
});
setApiKey('');
setApiKeyVisible(false);
// Trigger integration update to reload the integration state
if (onIntegrationUpdate && integration) {
await loadApiKeyFromSite();
// Reload integration to reflect changes
const integrations = await integrationApi.getSiteIntegrations(siteId);
const wp = integrations.find(i => i.platform === 'wordpress');
if (wp) {
onIntegrationUpdate(wp);
}
}
toast.success('API key revoked successfully');
} catch (error: any) {
toast.error(`Failed to revoke API key: ${error.message}`);
} finally {
setGeneratingKey(false);
}
};
const saveApiKeyToSite = async (key: string) => {
try {
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
@@ -146,108 +172,103 @@ export default function WordPressIntegrationForm({
toast.success('Plugin download started');
};
const handleSaveSettings = async () => {
try {
setLoading(true);
// Update integration with active/sync settings
if (integration) {
await integrationApi.updateIntegration(integration.id, {
is_active: isActive,
sync_enabled: syncEnabled,
} as any);
} else {
// Create integration if it doesn't exist
await integrationApi.saveWordPressIntegration(siteId, {
url: siteUrl || '',
username: '',
app_password: '',
api_key: apiKey,
is_active: isActive,
sync_enabled: syncEnabled,
});
}
// Reload integration
const updated = await integrationApi.getWordPressIntegration(siteId);
if (onIntegrationUpdate && updated) {
onIntegrationUpdate(updated);
}
toast.success('Integration settings saved successfully');
} catch (error: any) {
toast.error(`Failed to save settings: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleTestConnection = async () => {
if (!apiKey || !siteUrl) {
toast.error('Please ensure API key and site URL are configured');
return;
}
try {
setLoading(true);
// Test connection using API key and site URL
const result = await fetchAPI(`/v1/integration/integrations/test-connection/`, {
method: 'POST',
body: JSON.stringify({
site_id: siteId,
api_key: apiKey,
site_url: siteUrl,
}),
});
// Check for fully functional connection (includes bidirectional communication)
if (result?.fully_functional) {
toast.success('✅ Connection is fully functional! Plugin is connected and can communicate with IGNY8.');
} else if (result?.success) {
// Basic connection works but not fully functional
const issues = result?.issues || [];
const healthChecks = result?.health_checks || {};
// Show specific warning based on what's missing
if (!healthChecks.plugin_connected) {
toast.warning('⚠️ WordPress is reachable but the plugin is not configured with an API key. Please add the API key in your WordPress plugin settings.');
} else if (!healthChecks.bidirectional_communication) {
toast.warning('⚠️ Plugin is configured but cannot reach IGNY8 backend. Please check your WordPress site\'s outbound connections and firewall settings.');
} else {
toast.warning(`⚠️ ${result?.message || 'Connection partially working. Some features may not function correctly.'}`);
}
} else {
// Connection completely failed
toast.error(`❌ Connection test failed: ${result?.message || 'Unknown error'}`);
}
} catch (error: any) {
toast.error(`Connection test failed: ${error.message}`);
} finally {
setLoading(false);
}
};
const maskApiKey = (key: string) => {
if (!key) return '';
if (key.length <= 12) return key;
return key.substring(0, 8) + '**********' + key.substring(key.length - 4);
};
// Toggle integration enabled status
const [integrationEnabled, setIntegrationEnabled] = useState(integration?.is_active ?? true);
const handleToggleIntegration = async (enabled: boolean) => {
try {
setIntegrationEnabled(enabled);
if (integration) {
// Update existing integration
await integrationApi.updateIntegration(integration.id, {
is_active: enabled,
} as any);
toast.success(enabled ? 'Integration enabled' : 'Integration disabled');
// Reload integration
const updated = await integrationApi.getWordPressIntegration(siteId);
if (onIntegrationUpdate && updated) {
onIntegrationUpdate(updated);
}
} else if (enabled && apiKey) {
// Create integration when enabling for first time
await integrationApi.saveWordPressIntegration(siteId, {
url: siteUrl || '',
username: '',
app_password: '',
api_key: apiKey,
is_active: enabled,
sync_enabled: true,
});
toast.success('Integration created and enabled');
// Reload integration
const updated = await integrationApi.getWordPressIntegration(siteId);
if (onIntegrationUpdate && updated) {
onIntegrationUpdate(updated);
}
}
} catch (error: any) {
toast.error(`Failed to update integration: ${error.message}`);
// Revert on error
setIntegrationEnabled(!enabled);
}
};
useEffect(() => {
if (integration) {
setIntegrationEnabled(integration.is_active ?? true);
}
}, [integration]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-3 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<Globe className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
WordPress Integration
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Connect your WordPress site using the IGNY8 WP Bridge plugin
</p>
{/* Header with Toggle */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="p-3 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<Globe className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
WordPress Integration
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Connect your WordPress site using the IGNY8 WP Bridge plugin
</p>
</div>
</div>
{/* Toggle Switch */}
{apiKey && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{integrationEnabled ? 'Enabled' : 'Disabled'}
</span>
<button
type="button"
onClick={() => handleToggleIntegration(!integrationEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 ${
integrationEnabled ? 'bg-brand-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
role="switch"
aria-checked={integrationEnabled}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
integrationEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
)}
</div>
{/* API Keys Table */}
@@ -366,11 +387,19 @@ export default function WordPressIntegrationForm({
<button
onClick={handleRegenerateApiKey}
disabled={generatingKey}
className="text-gray-500 hover:text-error-500 dark:text-gray-400 dark:hover:text-error-500 disabled:opacity-50"
title="Regenerate"
className="text-gray-500 hover:text-brand-500 dark:text-gray-400 dark:hover:text-brand-400 disabled:opacity-50 transition-colors"
title="Regenerate API key"
>
<RefreshCw className={`w-5 h-5 ${generatingKey ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleRevokeApiKey}
disabled={generatingKey}
className="text-gray-500 hover:text-error-500 dark:text-gray-400 dark:hover:text-error-400 disabled:opacity-50 transition-colors"
title="Revoke API key"
>
<TrashBinIcon className="w-5 h-5" />
</button>
</div>
</td>
</tr>
@@ -407,104 +436,6 @@ export default function WordPressIntegrationForm({
</div>
</Card>
)}
{/* Integration Settings */}
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Integration Settings
</h3>
</div>
{/* Checkboxes at the top */}
<div className="space-y-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<Checkbox
id="is_active"
checked={isActive}
onChange={(checked) => setIsActive(checked)}
label="Enable Integration"
/>
<Checkbox
id="sync_enabled"
checked={syncEnabled}
onChange={(checked) => setSyncEnabled(checked)}
label="Enable Two-Way Sync"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4">
{apiKey && siteUrl && (
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Test Connection
</Button>
)}
<Button
type="button"
variant="solid"
onClick={handleSaveSettings}
disabled={loading || !apiKey}
>
{loading ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</Card>
{/* Integration Status */}
{integration && (
<Card className="p-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Integration Status
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Status</p>
<div className="flex items-center gap-2">
{integration.is_active ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<AlertIcon className="w-4 h-4 text-gray-400" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-white">
{integration.is_active ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Sync Status</p>
<div className="flex items-center gap-2">
{integration.sync_status === 'success' ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : integration.sync_status === 'failed' ? (
<AlertIcon className="w-4 h-4 text-red-500" />
) : (
<RefreshCw className="w-4 h-4 text-yellow-500 animate-spin" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-white capitalize">
{integration.sync_status || 'Pending'}
</span>
</div>
</div>
</div>
{integration.last_sync_at && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Sync</p>
<p className="text-sm text-gray-900 dark:text-white">
{new Date(integration.last_sync_at).toLocaleString()}
</p>
</div>
)}
</div>
</Card>
)}
</div>
);
}

View File

@@ -14,11 +14,13 @@ import SelectDropdown from '../../components/form/SelectDropdown';
import Checkbox from '../../components/form/input/Checkbox';
import TextArea from '../../components/form/input/TextArea';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI, runSync } from '../../services/api';
import { fetchAPI, runSync, fetchSites, Site } from '../../services/api';
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon } from '../../icons';
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon } from '../../icons';
import Badge from '../../components/ui/badge/Badge';
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
export default function SiteSettings() {
const { id: siteId } = useParams<{ id: string }>();
@@ -31,6 +33,12 @@ export default function SiteSettings() {
const [wordPressIntegration, setWordPressIntegration] = useState<SiteIntegration | null>(null);
const [integrationLoading, setIntegrationLoading] = useState(false);
// Site selector state
const [sites, setSites] = useState<Site[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false);
const siteSelectorRef = useRef<HTMLButtonElement>(null);
// Check for tab parameter in URL
const initialTab = (searchParams.get('tab') as 'general' | 'seo' | 'og' | 'schema' | 'integrations' | 'content-types') || 'general';
const [activeTab, setActiveTab] = useState<'general' | 'seo' | 'og' | 'schema' | 'integrations' | 'content-types'>(initialTab);
@@ -39,6 +47,7 @@ export default function SiteSettings() {
const [formData, setFormData] = useState({
name: '',
slug: '',
site_url: '',
site_type: 'marketing',
hosting_type: 'igny8_sites',
is_active: true,
@@ -61,6 +70,12 @@ export default function SiteSettings() {
useEffect(() => {
if (siteId) {
// Clear state when site changes
setWordPressIntegration(null);
setContentTypes(null);
setSite(null);
// Load new site data
loadSite();
loadIntegrations();
}
@@ -80,6 +95,29 @@ export default function SiteSettings() {
}
}, [activeTab, wordPressIntegration]);
// Load sites for selector
useEffect(() => {
loadSites();
}, []);
const loadSites = async () => {
try {
setSitesLoading(true);
const response = await fetchSites();
const activeSites = (response.results || []).filter(site => site.is_active);
setSites(activeSites);
} catch (error: any) {
console.error('Failed to load sites:', error);
} finally {
setSitesLoading(false);
}
};
const handleSiteSelect = (siteId: number) => {
navigate(`/sites/${siteId}/settings${searchParams.get('tab') ? `?tab=${searchParams.get('tab')}` : ''}`);
setIsSiteSelectorOpen(false);
};
const loadSite = async () => {
try {
setLoading(true);
@@ -90,6 +128,7 @@ export default function SiteSettings() {
setFormData({
name: data.name || '',
slug: data.slug || '',
site_url: data.domain || data.url || '',
site_type: data.site_type || 'marketing',
hosting_type: data.hosting_type || 'igny8_sites',
is_active: data.is_active !== false,
@@ -109,11 +148,7 @@ export default function SiteSettings() {
schema_logo: seoData.schema_logo || '',
schema_same_as: Array.isArray(seoData.schema_same_as) ? seoData.schema_same_as.join(', ') : seoData.schema_same_as || '',
});
// If integration record missing but site has stored WP API key or hosting_type wordpress, mark as connected-active
if (!wordPressIntegration && (data.wp_api_key || data.hosting_type === 'wordpress')) {
setIntegrationTestStatus('connected');
setIntegrationLastChecked(new Date().toISOString());
}
// Don't automatically mark as connected - wait for actual connection test
}
} catch (error: any) {
toast.error(`Failed to load site: ${error.message}`);
@@ -170,84 +205,51 @@ export default function SiteSettings() {
return `${months}mo ago`;
};
// Integration test status & periodic check (every 60 minutes)
const [integrationTestStatus, setIntegrationTestStatus] = useState<'connected' | 'pending' | 'error' | 'not_configured'>('not_configured');
const [integrationLastChecked, setIntegrationLastChecked] = useState<string | null>(null);
const integrationCheckRef = useRef<number | null>(null);
const integrationErrorCooldownRef = useRef<number | null>(null);
const [syncLoading, setSyncLoading] = useState(false);
const runIntegrationTest = async () => {
// respect cooldown on repeated server errors
if (integrationErrorCooldownRef.current && Date.now() < integrationErrorCooldownRef.current) {
return;
}
if (!wordPressIntegration && !site) {
setIntegrationTestStatus('not_configured');
return;
}
// Integration status with authentication check
const [integrationStatus, setIntegrationStatus] = useState<'connected' | 'configured' | 'not_configured'>('not_configured');
const [testingAuth, setTestingAuth] = useState(false);
// Check basic configuration (API key + toggle)
useEffect(() => {
const checkStatus = async () => {
if (wordPressIntegration && wordPressIntegration.is_active && site?.wp_api_key) {
setIntegrationStatus('configured');
// Test authentication
testAuthentication();
} else {
setIntegrationStatus('not_configured');
}
};
checkStatus();
}, [wordPressIntegration, site]);
// Test authentication with WordPress API
const testAuthentication = async () => {
if (testingAuth || !wordPressIntegration?.id) return;
try {
setIntegrationTestStatus('pending');
let resp: any = null;
// Only run server-side test if we have an integration record to avoid triggering collection-level 500s
if (wordPressIntegration && wordPressIntegration.id) {
resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, { method: 'POST', body: {} });
} else {
// If no integration record, do not call server test here — just mark connected if site has local WP credentials.
if (site?.wp_api_key || site?.wp_url || site?.hosting_type === 'wordpress') {
// Assume connected (plugin shows connection) but do not invoke server test to avoid 500s.
setIntegrationTestStatus('connected');
setIntegrationLastChecked(new Date().toISOString());
return;
} else {
setIntegrationTestStatus('not_configured');
return;
}
}
setTestingAuth(true);
const resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, {
method: 'POST',
body: {}
});
if (resp && resp.success) {
setIntegrationTestStatus('connected');
// clear any error cooldown
integrationErrorCooldownRef.current = null;
setIntegrationStatus('connected');
} else {
setIntegrationTestStatus('error');
// set cooldown to 60 minutes to avoid repeated 500s
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
// Keep as 'configured' if auth fails
setIntegrationStatus('configured');
}
setIntegrationLastChecked(new Date().toISOString());
} catch (err) {
setIntegrationTestStatus('error');
setIntegrationLastChecked(new Date().toISOString());
// Keep as 'configured' if auth test fails
setIntegrationStatus('configured');
} finally {
setTestingAuth(false);
}
};
useEffect(() => {
// run initial test when page loads / integration/site known
runIntegrationTest();
// schedule hourly checks (one per hour) — less intrusive
if (integrationCheckRef.current) {
window.clearInterval(integrationCheckRef.current);
integrationCheckRef.current = null;
}
integrationCheckRef.current = window.setInterval(() => {
runIntegrationTest();
}, 60 * 60 * 1000); // 60 minutes
return () => {
if (integrationCheckRef.current) {
window.clearInterval(integrationCheckRef.current);
integrationCheckRef.current = null;
}
};
}, [wordPressIntegration, site]);
// when contentTypes last_structure_fetch updates (content was synced), re-run test once
useEffect(() => {
if (contentTypes?.last_structure_fetch) {
runIntegrationTest();
}
}, [contentTypes?.last_structure_fetch]);
// Sync Now handler extracted
const [syncLoading, setSyncLoading] = useState(false);
const handleManualSync = async () => {
setSyncLoading(true);
try {
@@ -260,67 +262,10 @@ export default function SiteSettings() {
toast.error(res?.message || 'Sync failed to start');
}
} else {
// No integration record — attempt a site-level sync job instead of calling test-connection (avoids server 500 test endpoint)
// This will trigger the site sync runner which is safer and returns structured result
try {
const runResult = await runSync(Number(siteId), 'from_external');
if (runResult && runResult.sync_results) {
toast.success('Site sync started (from external).');
// Refresh integrations and content types after a short delay
setTimeout(async () => {
await loadIntegrations();
await loadContentTypes();
}, 2000);
setIntegrationTestStatus('connected');
setIntegrationLastChecked(new Date().toISOString());
} else {
toast.error('Failed to start site sync.');
setIntegrationTestStatus('error');
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
}
} catch (e: any) {
// If there are no active integrations, attempt a collection-level test (if site has WP creds),
// otherwise prompt the user to configure the integration.
const errResp = e?.response || {};
const errMessage = errResp?.error || e?.message || '';
if (errMessage && errMessage.includes('No active integrations found')) {
if (site?.wp_api_key || site?.wp_url) {
try {
const body = {
site_id: siteId ? Number(siteId) : undefined,
api_key: site?.wp_api_key,
site_url: site?.wp_url,
};
const resp = await fetchAPI(`/v1/integration/integrations/test-connection/`, { method: 'POST', body: JSON.stringify(body) });
if (resp && resp.success) {
toast.success('Connection verified (collection). Fetching integrations...');
setIntegrationTestStatus('connected');
setIntegrationLastChecked(new Date().toISOString());
await loadIntegrations();
await loadContentTypes();
} else {
toast.error(resp?.message || 'Connection test failed (collection).');
setIntegrationTestStatus('error');
}
} catch (innerErr: any) {
toast.error(innerErr?.message || 'Collection-level connection test failed.');
setIntegrationTestStatus('error');
}
} else {
toast.error('No active integrations found for this site. Please configure WordPress integration in the Integrations tab.');
setIntegrationTestStatus('not_configured');
}
} else {
toast.error(e?.message || 'Failed to run site sync.');
setIntegrationTestStatus('error');
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
}
}
toast.error('No integration configured. Please configure WordPress integration first.');
}
} catch (err: any) {
toast.error(`Sync failed: ${err?.message || String(err)}`);
setIntegrationTestStatus('error');
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
} finally {
setSyncLoading(false);
}
@@ -394,29 +339,82 @@ export default function SiteSettings() {
<div className="p-6">
<PageMeta title="Site Settings - IGNY8" />
<div className="flex items-center gap-4">
<PageHeader
title={site?.name ? `${site.name} — Site Settings` : 'Site Settings'}
badge={{ icon: <GridIcon />, color: 'blue' }}
hideSiteSector
/>
{/* Integration status indicator (larger) */}
<div className="flex items-center gap-3 ml-2">
<span
className={`inline-block w-6 h-6 rounded-full ${
integrationTestStatus === 'connected' ? 'bg-green-500' :
integrationTestStatus === 'pending' ? 'bg-yellow-400' :
integrationTestStatus === 'error' ? 'bg-red-500' : 'bg-gray-300'
}`}
title={`Integration status: ${integrationTestStatus}${integrationLastChecked ? ' • last checked ' + formatRelativeTime(integrationLastChecked) : ''}`}
<div className="flex items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-4">
<PageHeader
title={site?.name ? `${site.name} — Site Settings` : 'Site Settings'}
badge={{ icon: <GridIcon />, color: 'blue' }}
hideSiteSector
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{integrationTestStatus === 'connected' && 'Connected'}
{integrationTestStatus === 'pending' && 'Checking...'}
{integrationTestStatus === 'error' && 'Error'}
{integrationTestStatus === 'not_configured' && 'Not configured'}
</span>
{/* Integration status indicator */}
<div className="flex items-center gap-3 ml-2">
<span
className={`inline-block w-6 h-6 rounded-full ${
integrationStatus === 'connected' ? 'bg-green-500' :
integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300'
}`}
title={`Integration status: ${
integrationStatus === 'connected' ? 'Connected & Authenticated' :
integrationStatus === 'configured' ? 'Configured (testing...)' : 'Not configured'
}`}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{integrationStatus === 'connected' && 'Connected'}
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')}
{integrationStatus === 'not_configured' && 'Not configured'}
</span>
</div>
</div>
{/* Site Selector - Only show if more than 1 site */}
{!sitesLoading && sites.length > 1 && (
<div className="relative inline-block">
<button
ref={siteSelectorRef}
onClick={() => setIsSiteSelectorOpen(!isSiteSelectorOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors"
aria-label="Switch site"
>
<span className="flex items-center gap-2">
<GridIcon className="w-4 h-4 text-brand-500 dark:text-brand-400" />
<span className="max-w-[150px] truncate">{site?.name || 'Select Site'}</span>
</span>
<ChevronDownIcon className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${isSiteSelectorOpen ? 'rotate-180' : ''}`} />
</button>
<Dropdown
isOpen={isSiteSelectorOpen}
onClose={() => setIsSiteSelectorOpen(false)}
anchorRef={siteSelectorRef}
>
{sites.map((s) => (
<DropdownItem
key={s.id}
onItemClick={() => handleSiteSelect(s.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
site?.id === s.id
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{s.name}</span>
{site?.id === s.id && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
))}
</Dropdown>
</div>
)}
</div>
{/* Tabs */}
@@ -644,6 +642,17 @@ export default function SiteSettings() {
/>
</div>
<div>
<Label>Site URL</Label>
<input
type="text"
value={formData.site_url}
onChange={(e) => setFormData({ ...formData, site_url: e.target.value })}
placeholder="https://example.com"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<Label>Site Type</Label>
<SelectDropdown