/
home
/
techb158
/
cosmic-risk.abdallabala.com
/
src
/
app
/
integrations
/
[integrationId]
/
/home/techb158/cosmic-risk.abdallabala.com/src/app/integrations/[integrationId]
mkdir
upload
Name
Size
Mode
Actions
page.jsx
29072
0644
edit
dl
rm
Edit:
/home/techb158/cosmic-risk.abdallabala.com/src/app/integrations/[integrationId]/page.jsx
(29072B)
'use client'; import React, { useEffect, useState, useCallback } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { api } from '../../../lib/api-client'; const PROVIDER_LABELS = { TRELLO: 'Trello', JIRA: 'Jira', ASANA: 'Asana', MICROSOFT_PLANNER: 'Microsoft Planner' }; const PROVIDER_ICONS = { TRELLO: 'T', JIRA: 'J', ASANA: 'A', MICROSOFT_PLANNER: 'P' }; function credentialExample(provider) { if (provider === 'TRELLO') return '{\n "apiKey": "...",\n "token": "...",\n "listId": "trello-list-id"\n}'; if (provider === 'JIRA') return '{\n "apiEmail": "you@example.com",\n "apiToken": "...",\n "baseUrl": "https://your-domain.atlassian.net",\n "projectKey": "COS"\n}'; if (provider === 'ASANA') return '{\n "accessToken": "...",\n "projectGid": "asana-project-gid"\n}'; return '{\n "accessToken": "...",\n "planId": "planner-plan-id",\n "bucketId": "planner-bucket-id"\n}'; } function useToast() { const [toasts, setToasts] = useState([]); const add = useCallback((message, type = 'success') => { const id = Date.now() + Math.random(); setToasts(prev => [...prev, { id, message, type }]); setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000); }, []); return { toasts, add }; } function ToastContainer({ toasts }) { if (!toasts.length) return null; return ( <div className="toast-container"> {toasts.map(t => ( <div key={t.id} className={`toast ${t.type}`}>{t.message}</div> ))} </div> ); } function statusBadgeClass(status) { if (status === 'CONNECTED') return 'badge badge-green'; if (status === 'NEEDS_CONFIGURATION') return 'badge badge-warn'; return 'badge'; } export default function IntegrationManagePage() { const router = useRouter(); const params = useParams(); const integrationId = params.integrationId; const { toasts, add } = useToast(); const [integration, setIntegration] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const [saving, setSaving] = useState(false); const [credentialsText, setCredentialsText] = useState(''); const [expandedRun, setExpandedRun] = useState(null); const [discovering, setDiscovering] = useState(false); const [discoveredResources, setDiscoveredResources] = useState(null); const [statusMap, setStatusMap] = useState({}); const [selectedLists, setSelectedLists] = useState({}); const [importPreview, setImportPreview] = useState(null); const [importing, setImporting] = useState(false); const [form, setForm] = useState({ workspaceName: '', externalProjectKey: '', baseUrl: '', authMode: 'API_KEY', liveEnabled: false }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const load = useCallback(async () => { setLoading(true); setError(null); try { const data = await api.get(`/api/integrations/${integrationId}`); const int = data.integration; setIntegration(int); setForm({ workspaceName: int.workspaceName || '', externalProjectKey: int.externalProjectKey || '', baseUrl: int.baseUrl || '', authMode: int.authMode || 'API_KEY', liveEnabled: int.liveEnabled || false }); const savedMap = int.liveConfig?.statusListMap || {}; setStatusMap(savedMap); setSelectedLists(savedMap); } catch (e) { setError(e.message); } finally { setLoading(false); } }, [integrationId]); useEffect(() => { load(); }, [load]); const handleSaveSettings = async () => { setSaving(true); try { const data = await api.patch(`/api/integrations/${integrationId}`, { workspaceName: form.workspaceName, externalProjectKey: form.externalProjectKey, baseUrl: form.baseUrl, authMode: form.authMode, liveEnabled: form.liveEnabled }); setIntegration(data.integration); add('Settings saved'); } catch (e) { add(e.message || 'Failed to save settings', 'error'); } finally { setSaving(false); } }; const handleSaveCredentials = async () => { if (!credentialsText.trim()) { add('No credentials to save', 'error'); return; } let parsed; try { parsed = JSON.parse(credentialsText); } catch (_error) { add('Credential JSON is invalid', 'error'); return; } setSaving(true); try { await api.patch(`/api/integrations/${integrationId}`, { credentials: parsed }); setCredentialsText(''); add('Credentials saved (encrypted)'); } catch (e) { add(e.message || 'Failed to save credentials', 'error'); } finally { setSaving(false); } }; const runLiveAction = async (action) => { setBusyId(action); try { const path = action === 'test' ? `/api/integrations/${integrationId}/live/test` : action === 'live-sync' ? `/api/integrations/${integrationId}/live/sync` : `/api/integrations/${integrationId}/sync`; const result = await api.post(path, {}); const summary = result?.syncRun?.summary || result?.account || `${action} completed`; add(summary); await load(); } catch (e) { add(e.message || `${action} failed`, 'error'); } finally { setBusyId(null); } }; const handleDiscover = async () => { setDiscovering(true); setDiscoveredResources(null); try { const data = await api.get(`/api/integrations/${integrationId}/discover`); setDiscoveredResources(data.resources || []); if (!data.resources || data.resources.length === 0) add('No resources found. Check your credentials.', 'error'); } catch (e) { add(e.message || 'Discovery failed', 'error'); } finally { setDiscovering(false); } }; const handleImportPreview = async () => { setImportPreview(null); try { const data = await api.get(`/api/integrations/${integrationId}/pull-preview`); setImportPreview(data); if (data.newItems === 0) add('No new items to import. All items already mapped.'); else add(`Preview: ${data.newItems} new item(s) will be imported`); } catch (e) { add(e.message || 'Import preview failed', 'error'); } }; const handleImport = async () => { setImporting(true); try { const result = await api.post(`/api/integrations/${integrationId}/pull`, {}); add(`Import complete: ${result.createdCount} created, ${result.skippedCount} skipped, ${result.failedCount} failed.`); setImportPreview(null); await load(); } catch (e) { add(e.message || 'Import failed', 'error'); } finally { setImporting(false); } }; const selectList = (listId, listName) => { setForm(f => ({ ...f, externalProjectKey: listId })); add(`Selected list: ${listName}`); }; const setStatusList = (status, listId) => { setSelectedLists(prev => ({ ...prev, [status]: listId })); }; const saveStatusMapping = async () => { setSaving(true); try { await api.patch(`/api/integrations/${integrationId}`, { liveConfig: { ...(integration.liveConfig || {}), statusListMap: selectedLists } }); setStatusMap(selectedLists); add('Status→List mapping saved'); await load(); } catch (e) { add(e.message || 'Failed to save mapping', 'error'); } finally { setSaving(false); } }; const handleDelete = async () => { if (!window.confirm('Delete this integration? This cannot be undone.')) return; setBusyId('delete'); try { await api.del(`/api/integrations/${integrationId}`); add('Integration deleted'); router.push('/dashboard?tab=Integrations'); } catch (e) { add(e.message || 'Failed to delete', 'error'); } finally { setBusyId(null); } }; if (loading) { return <div className="shell-loading"><div className="spinner" /></div>; } if (error) { return ( <div style={{ padding: 40, textAlign: 'center' }}> <p style={{ color: 'var(--danger)' }}>{error}</p> <button className="button secondary small" onClick={() => router.push('/dashboard?tab=Integrations')}>Back to Integrations</button> </div> ); } const provider = integration.provider; const icon = PROVIDER_ICONS[provider] || '?'; const providerLabel = PROVIDER_LABELS[provider] || provider; const mappings = integration.mappings || []; const syncRuns = integration.syncRuns || []; return ( <div style={{ padding: 28, maxWidth: 960, margin: '0 auto' }}> <ToastContainer toasts={toasts} /> <div style={{ marginBottom: 24 }}> <a href="/dashboard?tab=Integrations" onClick={e => { e.preventDefault(); router.push('/dashboard?tab=Integrations'); }} style={{ color: 'var(--muted)', textDecoration: 'none', fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }} > ← Back to Integrations </a> </div> <div className="panel" style={{ padding: 24, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}> <div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--accent)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700 }}> {icon} </div> <div style={{ flex: 1 }}> <h1 style={{ margin: 0, fontSize: 24 }}>{providerLabel} Integration</h1> <p style={{ margin: '4px 0 0', color: 'var(--muted)', fontSize: 14 }}> {integration.workspaceName || integration.workspace?.name || ''} </p> </div> <div> <span className={statusBadgeClass(integration.connectionStatus)}> {integration.connectionStatus} </span> {integration.liveEnabled ? <span className="badge badge-green" style={{ marginLeft: 6 }}>Live API</span> : <span className="badge" style={{ marginLeft: 6 }}>Simulated Mapping</span>} </div> </div> <div style={{ display: 'flex', gap: 0, borderTop: '1px solid var(--line)', paddingTop: 12 }}> {[ { label: '1. Project', done: true }, { label: '2. Risks', done: (integration.mappings || []).length > 0 || (integration.syncRuns || []).length > 0 }, { label: '3. Integration', done: true }, { label: integration.liveEnabled ? '4. Live API' : '4. Simulated Sync', done: (integration.syncRuns || []).length > 0 } ].map((step, i) => ( <div key={step.label} style={{ flex: 1, textAlign: 'center', fontSize: 11, color: step.done ? 'var(--green)' : 'var(--muted)' }}> <span style={{ fontWeight: 700 }}>{step.done ? '✓' : '○'} </span>{step.label} </div> ))} </div> </div> <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Settings</h2> <div style={{ marginBottom: 10 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 3 }}>Workspace Name</label> <input className="input" value={form.workspaceName} onChange={e => set('workspaceName')(e.target.value)} style={{ width: '100%' }} /> </div> <div style={{ marginBottom: 10 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 3 }}>External Project / List / Plan ID</label> <input className="input" value={form.externalProjectKey} onChange={e => set('externalProjectKey')(e.target.value)} style={{ width: '100%' }} /> </div> {integration.liveEnabled && ( <> <div style={{ marginBottom: 10 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 3 }}>Base URL</label> <input className="input" value={form.baseUrl} onChange={e => set('baseUrl')(e.target.value)} style={{ width: '100%' }} /> </div> <div style={{ marginBottom: 10 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 3 }}>Auth Mode</label> <select className="input" value={form.authMode} onChange={e => set('authMode')(e.target.value)} style={{ width: '100%' }}> <option value="API_KEY">API_KEY</option> <option value="OAUTH">OAUTH</option> <option value="BASIC">BASIC</option> </select> </div> </> )} <button className="button small" onClick={handleSaveSettings} disabled={saving}> {saving ? 'Saving...' : 'Save Settings'} </button> </div> {!integration.liveEnabled && ( <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Simulated Sync</h2> <p className="muted" style={{ fontSize: 12, margin: '0 0 8px' }}> Map COSMIC risks into external work item records. No real API calls are made — only database mapping records are created. </p> <button className="button small" onClick={() => runLiveAction('sync')} disabled={busyId === 'sync'} > {busyId === 'sync' ? 'Syncing...' : 'Run Simulated Sync'} </button> </div> )} {provider === 'TRELLO' && ( <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Trello Boards</h2> <p className="muted" style={{ fontSize: 12, margin: '0 0 8px' }}> Discover your Trello boards and lists to find the correct list ID. Works in both modes. </p> <button className="button secondary small" onClick={handleDiscover} disabled={discovering}> {discovering ? 'Discovering...' : 'Discover Boards'} </button> {discoveredResources && discoveredResources.length > 0 && ( <div style={{ marginTop: 12 }}> {discoveredResources.map(board => ( <div key={board.id} style={{ marginBottom: 10, padding: 10, background: 'var(--bg)', borderRadius: 8 }}> <strong style={{ fontSize: 14 }}>{board.name}</strong> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}> {(board.lists || []).map(list => ( <button key={list.id} className="button secondary small" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => selectList(list.id, `${board.name} / ${list.name}`)} title="Click to use this list" > {list.name} </button> ))} </div> </div> ))} <p className="muted" style={{ fontSize: 11, marginTop: 4 }}>Click a list name to set it as the External Project / List ID.</p> </div> )} </div> )} {provider === 'TRELLO' && ( <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Status → List Mapping</h2> <p className="muted" style={{ fontSize: 12, margin: '0 0 8px' }}> Map each COSMIC risk status to a Trello list. Click "Discover Boards" above to populate the dropdowns. </p> {(() => { const allLists = discoveredResources ? discoveredResources.flatMap(b => (b.lists || [])) : []; const statuses = ['OPEN', 'IN_MITIGATION', 'ACCEPTED', 'CLOSED']; return statuses.map(status => ( <div key={status} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 12 }}> <span style={{ minWidth: 130, fontSize: 13, fontWeight: 600 }}>{status}</span> <select className="input" value={selectedLists[status] || ''} onChange={e => setStatusList(status, e.target.value)} style={{ flex: 1 }} > <option value="">— Select list —</option> {allLists.length === 0 && ( <option value="" disabled>Discover Boards above to populate lists</option> )} {allLists.map(list => ( <option key={list.id} value={list.id}>{list.parentName} / {list.name}</option> ))} </select> </div> )); })()} <div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}> <button className="button small" onClick={saveStatusMapping} disabled={saving}> {saving ? 'Saving...' : 'Save Mapping'} </button> {Object.keys(statusMap).length > 0 && ( <> <button className="button secondary small" onClick={async () => { setSelectedLists({}); await api.patch(`/api/integrations/${integrationId}`, { liveConfig: { ...(integration.liveConfig || {}), statusListMap: {} } }); setStatusMap({}); add('Mapping reset'); }}>Reset</button> <span className="muted" style={{ fontSize: 12 }}> Current: {Object.entries(statusMap).map(([s, l]) => { const found = discoveredResources?.flatMap(b => b.lists || []).find(lst => lst.id === l); return `${s}→${found ? found.name : l.slice(0, 8) + '...'}`; }).join(', ')} </span> </> )} </div> </div> )} {integration.liveEnabled && ( <> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}> <div className="panel" style={{ padding: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Live Credentials</h2> <p className="muted" style={{ fontSize: 12, margin: '0 0 8px' }}> Paste provider credential JSON. Values are encrypted via AES-256-GCM and stored in OAuthToken records. </p> <textarea value={credentialsText} onChange={e => setCredentialsText(e.target.value)} placeholder={credentialExample(provider)} rows={8} style={{ width: '100%', border: '1px solid var(--border)', borderRadius: 8, padding: 10, fontFamily: 'monospace', fontSize: 12, resize: 'vertical' }} /> <div style={{ marginTop: 8, display: 'flex', gap: 8 }}> <button className="button small" onClick={handleSaveCredentials} disabled={saving || !credentialsText.trim()}> {saving ? 'Saving...' : 'Save Credentials'} </button> <button className="button secondary small" onClick={() => setCredentialsText('')}>Clear</button> </div> </div> <div className="panel" style={{ padding: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Live Actions</h2> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <button className="button small" onClick={() => runLiveAction('test')} disabled={busyId === 'test'}> {busyId === 'test' ? 'Testing...' : 'Test Live'} </button> <button className="button small" onClick={() => runLiveAction('live-sync')} disabled={busyId === 'live-sync'}> {busyId === 'live-sync' ? 'Syncing...' : 'Live Sync'} </button> </div> </div> </div> </> )} <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}>Import from {providerLabel}</h2> <p className="muted" style={{ fontSize: 12, margin: '0 0 8px' }}> Fetch items from {providerLabel} and create COSMIC risks from them. Items already imported are skipped. </p> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}> <button className="button small" onClick={handleImportPreview} disabled={busyId === 'import-preview'}> Preview Import </button> <button className="button small" onClick={handleImport} disabled={importing || busyId === 'import'}> {importing ? 'Importing...' : 'Import Items'} </button> </div> {importPreview && ( <div style={{ background: 'var(--bg)', borderRadius: 8, padding: 12, fontSize: 13 }}> <strong>Preview: {importPreview.newItems} new / {importPreview.existingItems} existing / {importPreview.totalItems} total</strong> {importPreview.preview && importPreview.preview.length > 0 && ( <div style={{ marginTop: 8, maxHeight: 300, overflowY: 'auto' }}> <table className="data-table" style={{ width: '100%', fontSize: 12 }}> <thead> <tr> <th>Title</th> <th>External ID</th> <th>Status</th> <th>Due</th> </tr> </thead> <tbody> {importPreview.preview.map((item, i) => ( <tr key={item.externalId || i}> <td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</td> <td style={{ fontFamily: 'monospace', fontSize: 11 }}>{item.externalId}</td> <td>{item.status}</td> <td style={{ whiteSpace: 'nowrap' }}>{item.dueDate ? new Date(item.dueDate).toLocaleDateString() : '-'}</td> </tr> ))} </tbody> </table> </div> )} {importPreview.preview && importPreview.preview.length === 0 && ( <p className="muted" style={{ marginTop: 8, fontSize: 12 }}>All items already imported. Nothing to create.</p> )} </div> )} </div> <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}> Sync History <span className="muted" style={{ fontSize: 12, fontWeight: 400, marginLeft: 8 }}>({syncRuns.length} runs)</span> </h2> {syncRuns.length === 0 ? ( <p className="muted" style={{ fontSize: 13 }}>No sync runs yet.</p> ) : ( <div style={{ overflowX: 'auto' }}> <table className="data-table" style={{ width: '100%', fontSize: 13 }}> <thead> <tr> <th>Status</th> <th>Started</th> <th>Created</th> <th>Updated</th> <th>Failed</th> <th>Summary</th> <th></th> </tr> </thead> <tbody> {syncRuns.map(run => { const show = expandedRun === run.id; const failures = Array.isArray(run.failureLog) ? run.failureLog : []; return ( <React.Fragment key={run.id}> <tr> <td><span className={`badge ${run.status === 'Completed' ? 'badge-green' : run.status === 'Partial' ? 'badge-warn' : ''}`}>{run.status}</span></td> <td style={{ whiteSpace: 'nowrap' }}>{new Date(run.startedAt).toLocaleString()}</td> <td>{run.createdCount}</td> <td>{run.updatedCount}</td> <td>{run.failedCount > 0 ? <span style={{ color: 'var(--danger)' }}>{run.failedCount}</span> : run.failedCount}</td> <td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{run.summary || ''}</td> <td> {failures.length > 0 && ( <button className="button secondary small" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setExpandedRun(show ? null : run.id)}> {show ? 'Hide' : 'Errors'} </button> )} </td> </tr> {show && failures.length > 0 && ( <tr> <td colSpan={7} style={{ padding: '8px 16px', background: 'var(--blocked-soft)' }}> <pre style={{ fontSize: 11, margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}> {failures.map((f, i) => `${i + 1}. ${f.title || f.riskId || 'Risk'}: ${f.error}${f.status ? ` (HTTP ${f.status})` : ''}`).join('\n')} </pre> </td> </tr> )} </React.Fragment> ); })} </tbody> </table> </div> )} </div> <div className="panel" style={{ padding: 20, marginBottom: 20 }}> <h2 style={{ margin: '0 0 12px', fontSize: 16 }}> Mappings <span className="muted" style={{ fontSize: 12, fontWeight: 400, marginLeft: 8 }}>({mappings.length} items)</span> </h2> {mappings.length === 0 ? ( <p className="muted" style={{ fontSize: 13 }}>No mappings yet. Run a sync to create mappings.</p> ) : ( <div style={{ overflowX: 'auto' }}> <table className="data-table" style={{ width: '100%', fontSize: 13 }}> <thead> <tr> <th>Risk Title</th> <th>External ID</th> <th>External Key</th> <th>External URL</th> <th>Status</th> <th>Sync Status</th> <th>Details</th> <th>Last Synced</th> </tr> </thead> <tbody> {mappings.map(m => { const fm = m.fieldMapping || {}; const isSimulatedTrello = fm.mode === 'simulated' && provider === 'TRELLO'; return ( <tr key={m.id}> <td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.localTitle}</td> <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{m.externalItemId}</td> <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{m.externalItemKey}</td> <td> {m.externalUrl ? ( <a href={m.externalUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)', fontSize: 12 }}>Open</a> ) : '-'} </td> <td>{m.externalStatus}</td> <td><span className="badge">{m.syncStatus}</span></td> <td style={{ fontSize: 11 }}> {isSimulatedTrello ? ( <span title={JSON.stringify(fm, null, 2)}> List: {fm.simulatedListId ? fm.simulatedListId.slice(0, 12) + '...' : '-'} {fm.simulatedLabels ? ` (${fm.simulatedLabels.length} labels)` : ''} </span> ) : fm.mode === 'simulated' ? ( <span className="muted">Simulated</span> ) : '-'} </td> <td style={{ whiteSpace: 'nowrap', fontSize: 12 }}>{m.lastSyncedAt ? new Date(m.lastSyncedAt).toLocaleString() : '-'}</td> </tr> ); })} </tbody> </table> </div> )} </div> <div style={{ textAlign: 'right', borderTop: '1px solid var(--line)', paddingTop: 20 }}> <button className="button secondary small" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={busyId === 'delete'} > {busyId === 'delete' ? 'Deleting...' : 'Delete Integration'} </button> </div> </div> ); }
Save
cmd:
run