import { useState, useEffect, useCallback } from 'react';
import { AppLayout } from '../../components/layout/AppLayout';
import { connectorsApi } from '../../api/connectors';
import type { ConnectorDef, ConnectorRecord, ConnectorLog, ConfigField } from '../../api/connectors';
import { useAuth } from '../../contexts/AuthContext';
import {
Plug,
Plus,
X,
RefreshCw,
Trash2,
CheckCircle2,
AlertCircle,
Loader2,
ArrowRight,
Clock,
ExternalLink,
Activity,
Shield,
Eye,
} from 'lucide-react';
// Map service name to icon color/category
const categoryConfig: Record<string, { bg: string; icon: string; label: string }> = {
crm: { bg: 'bg-blue-100', icon: 'text-blue-600', label: 'CRM' },
accounting: { bg: 'bg-green-100', icon: 'text-green-600', label: 'Accounting' },
advertising: { bg: 'bg-purple-100', icon: 'text-purple-600', label: 'Advertising' },
automation: { bg: 'bg-orange-100', icon: 'text-orange-600', label: 'Automation' },
telephony: { bg: 'bg-amber-100', icon: 'text-amber-600', label: 'Telephony' },
default: { bg: 'bg-surface-100', icon: 'text-surface-600', label: 'Integration' },
};
function getCategoryConfig(service: string) {
// Infer category from service name
if (service === 'hubspot') return categoryConfig.crm;
if (service === 'quickbooks') return categoryConfig.accounting;
if (service === 'google_ads' || service === 'googleads') return categoryConfig.advertising;
if (service === 'zapier') return categoryConfig.automation;
if (service === 'sms') return categoryConfig.telephony;
return categoryConfig.default;
}
function statusConfig(status: string) {
switch (status) {
case 'connected':
return { icon: CheckCircle2, color: 'text-green-600', label: 'Connected' };
case 'syncing':
return { icon: RefreshCw, color: 'text-blue-600', label: 'Syncing...' };
case 'error':
return { icon: AlertCircle, color: 'text-red-600', label: 'Error' };
default:
return { icon: Clock, color: 'text-surface-400', label: 'Inactive' };
}
}
// Connector card for available (not yet connected) integrations
function AvailableCard({
connector,
onConnect,
disabled,
}: {
connector: ConnectorDef;
onConnect: (c: ConnectorDef) => void;
disabled?: boolean;
}) {
const cat = getCategoryConfig(connector.service);
return (
<div className={`group relative flex flex-col rounded-xl border border-surface-200 bg-white p-5 sm:p-6 transition-shadow hover:shadow-md ${disabled ? 'opacity-60' : ''}`}>
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${cat.bg}`}>
<Plug className={`h-5 w-5 ${cat.icon}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-surface-900">{connector.name}</h3>
<p className="text-xs text-surface-500">{cat.label}</p>
</div>
</div>
<span className="rounded-full bg-surface-100 px-2 py-0.5 text-[10px] font-medium text-surface-500">
{connector.auth_type === 'oauth2' ? 'OAuth' : 'API Key'}
</span>
</div>
<p className="mb-4 text-sm text-surface-600 flex-1">{connector.description}</p>
<div className="mb-4 flex flex-wrap gap-1.5">
{(connector.capabilities || []).slice(0, 4).map((cap) => (
<span
key={cap}
className="rounded-md bg-surface-50 px-2 py-0.5 text-[10px] font-medium text-surface-500"
>
{cap}
</span>
))}
{connector.capabilities && connector.capabilities.length > 4 && (
<span className="rounded-md bg-surface-50 px-2 py-0.5 text-[10px] font-medium text-surface-500">
+{connector.capabilities.length - 4} more
</span>
)}
</div>
<div className="mt-auto flex items-center justify-between border-t border-surface-100 pt-4">
<a
href={connector.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-surface-400 hover:text-surface-600 min-h-[44px]"
>
Docs <ExternalLink className="h-3 w-3" />
</a>
{disabled ? (
<span className="flex items-center gap-1.5 rounded-lg bg-surface-100 px-3 py-2 text-sm font-medium text-surface-400 cursor-not-allowed min-h-[44px]">
<Eye className="h-4 w-4" />
View Only
</span>
) : (
<button
onClick={() => onConnect(connector)}
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 min-h-[44px]"
>
<Plus className="h-4 w-4" />
Connect
</button>
)}
</div>
</div>
);
}
// Connector card for active connections
function ActiveCard({
connector,
onSync,
onDisconnect,
onReconnect,
syncing,
readOnly,
isOAuth,
}: {
connector: ConnectorRecord;
onSync: (id: string) => void;
onDisconnect: (id: string) => void;
onReconnect?: (c: ConnectorRecord) => void;
syncing: boolean;
readOnly?: boolean;
isOAuth?: boolean;
}) {
const [showLogs, setShowLogs] = useState(false);
const [logs, setLogs] = useState<ConnectorLog[]>([]);
const status = statusConfig(connector.status);
const StatusIcon = status.icon;
const loadLogs = useCallback(async () => {
try {
const result = await connectorsApi.logs(connector.id);
setLogs(result.logs);
} catch {
setLogs([]);
}
}, [connector.id]);
const toggleLogs = async () => {
if (!showLogs) {
await loadLogs();
}
setShowLogs((v) => !v);
};
return (
<div className="relative flex flex-col rounded-xl border border-surface-200 bg-white p-5 sm:p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${getCategoryConfig(connector.service).bg}`}>
<Plug className={`h-5 w-5 ${getCategoryConfig(connector.service).icon}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-surface-900 capitalize">{connector.service.replace('_', ' ')}</h3>
<div className={`flex items-center gap-1.5 ${status.color}`}>
<StatusIcon className={`h-3.5 w-3.5 ${syncing ? 'animate-spin' : ''}`} />
<span className="text-xs font-medium">{status.label}</span>
{connector.last_sync_at && (
<span className="text-xs text-surface-400">
{' ยท '}Last synced {new Date(connector.last_sync_at).toLocaleString()}
</span>
)}
</div>
</div>
</div>
<select
value={connector.sync_frequency}
onChange={(e) => {
// For now, just visual โ syncing frequency requires backend update endpoint
}}
className="rounded-lg border border-surface-200 bg-surface-50 px-2 py-1 text-xs font-medium text-surface-600 min-h-[44px]"
>
<option value="real_time">Real-time</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
</select>
</div>
{connector.error_message && (
<div className="mb-4 flex items-start gap-2 rounded-lg bg-red-50 p-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
<p className="text-xs text-red-700">{connector.error_message}</p>
</div>
)}
<div className="mt-auto flex items-center gap-2 border-t border-surface-100 pt-4">
{readOnly ? (
<span className="flex items-center gap-1.5 rounded-lg bg-surface-100 px-3 py-2 text-sm font-medium text-surface-400 cursor-not-allowed min-h-[44px]">
<Shield className="h-4 w-4" />
Read Only
</span>
) : (
<>
<button
onClick={() => onSync(connector.id)}
disabled={syncing || connector.status === 'syncing'}
className="flex items-center gap-1.5 rounded-lg bg-surface-100 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-200 disabled:opacity-50 min-h-[44px]"
>
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
Sync Now
</button>
<button
onClick={toggleLogs}
className="flex items-center gap-1.5 rounded-lg bg-surface-100 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-200 min-h-[44px]"
>
<Activity className="h-4 w-4" />
Logs
</button>
{isOAuth && onReconnect && (
<button
onClick={() => onReconnect(connector)}
className="flex items-center gap-1.5 rounded-lg bg-brand-50 px-3 py-2 text-sm font-medium text-brand-700 transition-colors hover:bg-brand-100 min-h-[44px]"
>
<Plug className="h-4 w-4" />
Reconnect
</button>
)}
<button
onClick={() => onDisconnect(connector.id)}
className="ml-auto flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 min-h-[44px]"
>
<Trash2 className="h-4 w-4" />
Disconnect
</button>
</>
)}
</div>
{/* Expandable logs */}
{showLogs && (
<div className="mt-4 max-h-48 overflow-y-auto rounded-lg border border-surface-200 bg-surface-50 p-3">
{logs.length === 0 ? (
<p className="text-xs text-surface-400">No sync logs yet</p>
) : (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="flex items-start gap-2 text-xs">
<span
className={`shrink-0 font-medium ${
log.status === 'success'
? 'text-green-600'
: log.status === 'error'
? 'text-red-600'
: 'text-surface-400'
}`}
>
{log.status}
</span>
<span className="text-surface-600">{log.event_type}</span>
{log.record_count > 0 && (
<span className="text-surface-400">{log.record_count} records</span>
)}
{log.duration_ms != null && (
<span className="text-surface-400">{log.duration_ms}ms</span>
)}
<span className="ml-auto shrink-0 text-surface-300">
{new Date(log.created_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// Connect modal
function ConnectModal({
connector,
onClose,
onSuccess,
}: {
connector: ConnectorDef;
onClose: () => void;
onSuccess: () => void;
}) {
const [config, setConfig] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await connectorsApi.create(connector.service, config, 'hourly');
if (result.connector.status === 'connected') {
onSuccess();
} else {
setError(result.connector.error_message || 'Connection failed');
}
} catch (err: unknown) {
setError(
(err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Connection failed'
);
} finally {
setLoading(false);
}
};
// Use config_fields if available, otherwise fall back to auth_fields
const fields: ConfigField[] = connector.config_fields || connector.auth_fields?.map(f => ({
name: f,
label: f.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
type: f.includes('token') || f.includes('secret') || f.includes('password') ? 'password' : 'text',
required: true,
})) || [];
// Determine which fields to show based on provider selection
const selectedProvider = config.provider;
const shouldShowField = (field: ConfigField) => {
if (field.name === 'account_sid') return selectedProvider === 'twilio';
if (field.name === 'auth_id') return selectedProvider === 'plivo';
return true; // show all other fields
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-surface-900">Connect {connector.name}</h2>
<button
onClick={onClose}
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-lg text-surface-400 hover:bg-surface-100 hover:text-surface-600"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => {
if (!shouldShowField(field)) return null;
const label = field.label || field.name.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());
const fieldType = field.type || 'text';
const isRequired = field.required ?? true;
return (
<div key={field.name}>
<label className="mb-1 block text-sm font-medium text-surface-700">
{label}
{!isRequired && <span className="text-surface-400"> (optional)</span>}
</label>
{fieldType === 'select' ? (
<select
className="w-full rounded-lg border border-surface-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200 min-h-[44px] bg-white"
value={config[field.name] || ''}
onChange={(e) => setConfig((prev) => ({ ...prev, [field.name]: e.target.value }))}
required={isRequired}
>
<option value="">Select provider...</option>
{(field.options || []).map((opt: string) => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
</select>
) : (
<input
type={fieldType}
className="w-full rounded-lg border border-surface-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200 min-h-[44px]"
placeholder={`Enter ${label.toLowerCase()}`}
value={config[field.name] || ''}
onChange={(e) => setConfig((prev) => ({ ...prev, [field.name]: e.target.value }))}
required={isRequired}
/>
)}
</div>
);
})}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
<p className="text-xs text-red-700">{error}</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-surface-200 bg-white px-4 py-3 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 min-h-[44px]"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRight className="h-4 w-4" />
Connect
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}
// โโโ Role / access helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/** Detect demo accounts by email suffix (e.g. demo@commandcenter.com). */
function isDemoAccount(email?: string | null): boolean {
if (!email) return false;
const lower = email.toLowerCase();
return lower.startsWith('demo@') || lower === 'demo';
}
// Main page
export function IntegrationsPage() {
const { user, hasCompanyPermission } = useAuth();
const [available, setAvailable] = useState<ConnectorDef[]>([]);
const [connected, setConnected] = useState<ConnectorRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncingId, setSyncingId] = useState<string | null>(null);
const [connectModal, setConnectModal] = useState<ConnectorDef | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// โโโ Access control derived state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canEdit = hasCompanyPermission('edit_integrations');
const isDemo = isDemoAccount(user?.email);
const isReadOnly = isDemo || !canEdit;
const fetchData = useCallback(async () => {
try {
const [availRes, listRes] = await Promise.all([
connectorsApi.available(),
connectorsApi.list(),
]);
setAvailable(availRes.connectors);
setConnected(listRes.connectors);
} catch {
setToast({ message: 'Failed to load integrations', type: 'error' });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSync = async (id: string) => {
if (isReadOnly) return;
setSyncingId(id);
try {
await connectorsApi.sync(id);
setToast({ message: 'Sync completed', type: 'success' });
await fetchData();
} catch (err: unknown) {
setToast({ message: (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Sync failed', type: 'error' });
} finally {
setSyncingId(null);
}
};
const handleDisconnect = async (id: string) => {
if (isReadOnly) return;
try {
await connectorsApi.remove(id);
setToast({ message: 'Integration disconnected', type: 'success' });
await fetchData();
} catch (err: unknown) {
setToast({ message: (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to disconnect', type: 'error' });
}
};
const handleConnectClick = (connector: ConnectorDef) => {
if (isReadOnly) return;
// OAuth2 connectors redirect to provider login; manual connectors show modal
if (connector.auth_type === 'oauth2') {
window.location.href = `/api/connectors/oauth/authorize/${connector.service}`;
} else {
setConnectModal(connector);
}
};
const handleReconnect = (connector: ConnectorRecord) => {
if (isReadOnly) return;
window.location.href = `/api/connectors/oauth/authorize/${connector.service}`;
};
const connectedServices = new Set(connected.map((c) => c.service));
const unconnected = available.filter((a) => !connectedServices.has(a.service));
// Handle OAuth callback success/error from URL params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const success = params.get('oauth_success');
const error = params.get('oauth_error');
const message = params.get('message');
if (success) {
setToast({ message: `${success} connected successfully!`, type: 'success' });
fetchData();
params.delete('oauth_success');
window.history.replaceState({}, '', window.location.pathname);
} else if (error) {
setToast({ message: `${message || error} connection failed. Please try again.`, type: 'error' });
params.delete('oauth_error');
params.delete('message');
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
// Auto-dismiss toast
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}
}, [toast]);
return (
<AppLayout title="Integrations">
{/* Toast */}
{toast && (
<div className="fixed top-4 right-4 z-[70] flex items-center gap-2 rounded-lg bg-surface-900 px-4 py-3 text-sm text-white shadow-lg">
{toast.type === 'success' ? (
<CheckCircle2 className="h-4 w-4 text-green-400" />
) : (
<AlertCircle className="h-4 w-4 text-red-400" />
)}
{toast.message}
</div>
)}
<div data-testid="page-content" className="space-y-6 sm:space-y-8">
{/* Demo / read-only banner */}
{isReadOnly && (
<div className={`flex items-start gap-3 rounded-xl border p-4 ${
isDemo
? 'border-amber-200 bg-amber-50'
: 'border-blue-200 bg-blue-50'
}`}>
<Shield className={`mt-0.5 h-5 w-5 shrink-0 ${
isDemo ? 'text-amber-600' : 'text-blue-600'
}`} />
<div>
<p className={`text-sm font-semibold ${
isDemo ? 'text-amber-800' : 'text-blue-800'
}`}>
{isDemo ? 'Demo Mode' : 'View Only'}
</p>
<p className={`mt-0.5 text-xs ${
isDemo ? 'text-amber-700' : 'text-blue-700'
}`}>
{isDemo
? 'This is a demo account. You can browse available integrations but cannot create, modify, or remove connectors. Contact your admin for full access.'
: 'Your current role does not have permission to manage integrations. Contact your company admin or owner to make changes.'}
</p>
</div>
</div>
)}
{/* Header */}
<div>
<h1 className="text-xl sm:text-2xl font-bold text-surface-900">Integrations</h1>
<p className="mt-1 text-sm text-surface-500">
Connect your existing tools to pull live data into Command Sovereignty.
</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
) : (
<>
{/* Connected integrations */}
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-surface-700">
Connected ({connected.length})
</h2>
</div>
{connected.length === 0 ? (
<div className="rounded-xl border border-dashed border-surface-200 bg-surface-50 p-8 text-center">
<Plug className="mx-auto mb-3 h-8 w-8 text-surface-300" />
<p className="text-sm font-medium text-surface-500">No integrations connected yet</p>
<p className="mt-1 text-xs text-surface-400">
Connect your CRM, accounting, or advertising tools to start syncing data.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{connected.map((c) => (
<ActiveCard
key={c.id}
connector={c}
onSync={handleSync}
onDisconnect={handleDisconnect}
onReconnect={handleReconnect}
syncing={syncingId === c.id}
readOnly={isReadOnly}
isOAuth={c.auth_type === 'oauth2'}
/>
))}
</div>
)}
</section>
{/* Available integrations */}
{unconnected.length > 0 && (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-surface-700">
Available Integrations ({unconnected.length})
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{unconnected.map((c) => (
<AvailableCard
key={c.service}
connector={c}
onConnect={handleConnectClick}
disabled={isReadOnly}
/>
))}
</div>
</section>
)}
</>
)}
</div>
{/* Connect modal โ only shown if user has edit permission */}
{connectModal && (
<ConnectModal
connector={connectModal}
onClose={() => setConnectModal(null)}
onSuccess={() => {
setConnectModal(null);
fetchData();
}}
/>
)}
</AppLayout>
);
}