/home/techb158/balavpn.abdallabala.com/src/app/dashboard
Edit: /home/techb158/balavpn.abdallabala.com/src/app/dashboard/page.jsx (127578B)
'use client';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { api } from '../../lib/api-client';
import ErrorBoundary from '../../components/ErrorBoundary';
const ORG_TABS = ['Overview', 'Projects', 'Risks', 'Mitigations', 'Gate', 'Indicators', 'Experiments', 'Reports', 'Integrations', 'Audit', 'Members'];
const PAGE_SIZE = 20;
const DIMENSIONS = ['Organizational', 'Technical', 'Human', 'Governance', 'Legal', 'Ethical', 'Operational'];
const LIFECYCLE_PHASES = ['Design', 'Development', 'Testing', 'Deployment'];
const RISK_STATUSES = ['OPEN', 'IN_MITIGATION', 'ACCEPTED', 'CLOSED'];
const MITIGATION_STATUSES = ['NOT_STARTED', 'IN_PROGRESS', 'DONE', 'REJECTED'];
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 (
{toasts.map(t => (
{t.message}
))}
);
}
function SearchInput({ value, onChange, placeholder }) {
return (
⌕
onChange(e.target.value)} placeholder={placeholder || 'Search...'} />
);
}
function sortData(list, sortKey, sortDir) {
if (!sortKey) return list;
return [...list].sort((a, b) => {
const va = a[sortKey], vb = b[sortKey];
if (va == null) return 1; if (vb == null) return -1;
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
return sortDir === 'asc' ? cmp : -cmp;
});
}
function Badge({ children, className }) {
return
{children};
}
function FormField({ label, children }) {
return (
{children}
);
}
function InlineInput({ value, onChange, type = 'text', min, max, style: extraStyle }) {
return
onChange(e.target.value)} min={min} max={max} style={{ width: '100%', ...extraStyle }} />;
}
function InlineSelect({ value, onChange, options }) {
const items = options || [];
return (
);
}
function ConfirmDelete({ label, onConfirm, onCancel, deleting }) {
return (
e.stopPropagation()} style={{ maxWidth: 380 }}>
Delete {label}?
This cannot be undone.
);
}
function OverviewTab({ project, dashboard }) {
const panels = useMemo(() => {
if (!dashboard) return [];
const s = dashboard.summary || {};
return [
{ label: 'Overall AI Risk Score', value: `${s.overallScore ?? '-'}/100`, cls: (s.overallScore || 0) >= 50 ? 'bad' : (s.overallScore || 0) >= 25 ? 'warn' : 'good', sub: `${s.riskLevel || '-'} residual exposure` },
{ label: 'Deployment Gate', value: s.gateStatus ?? '-', cls: s.gateStatus === 'READY' ? 'good' : s.gateStatus === 'WARNING' ? 'warn' : 'bad', sub: dashboard.gate?.message || '' },
{ label: 'Open Risks', value: s.openRisks ?? 0, cls: (s.openRisks || 0) > 10 ? 'bad' : (s.openRisks || 0) > 3 ? 'warn' : 'good', sub: `${s.totalRisks || 0} total risks tracked` },
{ label: 'Mitigation Completion', value: `${s.mitigationCompletion ?? 0}%`, cls: (s.mitigationCompletion || 0) < 40 ? 'bad' : (s.mitigationCompletion || 0) < 70 ? 'warn' : 'good', sub: 'Average progress across active risks' },
{ label: 'Critical Risks', value: s.criticalRisks ?? 0, cls: (s.criticalRisks || 0) > 0 ? 'bad' : 'good', sub: `${s.highRisks || 0} high risks also active` },
];
}, [dashboard]);
if (!dashboard) return
Loading dashboard data...
;
const s = dashboard.summary || {};
const triangle = dashboard.governanceTriangle || {};
const lifecycleReadiness = dashboard.lifecycleReadiness || [];
const dimensionResidual = dashboard.dimensionResidual || [];
const dims = dimensionResidual.length > 0 ? dimensionResidual : [
{ name: 'Organizational', score: triangle.organizational || 0 },
{ name: 'Technical', score: triangle.technical || 0 },
{ name: 'Human', score: triangle.human || 0 },
];
const getDim = (name) => { const d = dims.find(x => x.name.toLowerCase() === name.toLowerCase()); return d ? Math.round(d.score || 0) : 0; };
const orgScore = getDim('Organizational');
const techScore = getDim('Technical');
const humanScore = getDim('Human');
const triClass = (s) => s >= 75 ? 'blocked' : s >= 50 ? 'warning' : 'pass';
const topRisks = dashboard.topRisks || [];
return (
<>
Integrated Measurement Framework for AI Project Risks
{project?.projectType || 'AI-Enabler'} · {project?.currentLifecyclePhase || 'Assessment'} · Assessment {new Date(dashboard.project?.assessmentDate || Date.now()).toISOString().split('T')[0]}
{/* Summary Cards */}
{panels.map(p => (
{p.label}
{p.value}
{p.sub}
))}
{/* AI Governance Triangle */}
6.2
AI Governance Triangle
Organizational, technical, and human residual risk.
{/* Dimension Scores */}
Risk dimensions
Dimension scores
Normalized residual risk exposure by dashboard dimension.
{dims.map(d => {
const score = Math.round(d.score || 0);
const barColor = score >= 75 ? 'var(--blocked)' : score >= 50 ? 'var(--warn)' : 'var(--accent)';
return (
);
})}
{/* Lifecycle Readiness Map */}
AI lifecycle
Lifecycle readiness map
Dashboard backbone from needs identification through deployment and monitoring.
{lifecycleReadiness.map((phase, i) => {
const readinessColor = phase.readinessScore >= 70 ? 'var(--accent)' : phase.readinessScore >= 50 ? 'var(--warn)' : 'var(--danger)';
return (
{phase.code || `LC-${String(i + 1).padStart(2, '0')}`}
{phase.name}
Readiness {phase.readinessScore || 0}/100
{phase.openRisks} open risks
);
})}
{/* Top Risks */}
Top Risks
Highest residual risk exposure — Prioritized by current residual score after mitigation progress.
{topRisks.length === 0 ? (
) : (
| ID |
Risk |
Dimension |
Residual |
Level |
Owner |
{topRisks.map((risk, i) => {
const levelCls = risk.residualSeverity === 'Critical' ? 'bad' : risk.residualSeverity === 'High' ? 'bad' : risk.residualSeverity === 'Moderate' ? 'warn' : 'good';
return (
| {risk.id ? `R-${String(risk.id).slice(-4).toUpperCase()}` : `R-${String(i + 1).padStart(3, '0')}`} |
{risk.title || risk.description || 'Untitled'} |
{risk.dimension || '-'} |
{risk.residualScore}/100 |
{risk.residualSeverity || 'Unknown'} |
{risk.ownerDisplayName || 'Unassigned'} |
);
})}
)}
>
);
}
function RiskFormModal({ project, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ title: '', description: '', dimension: '', domain: '', lifecyclePhase: '', probability: 50, impact: 50, detectability: 50, ownerDisplayName: '' });
const set = key => value => setForm(f => ({ ...f, [key]: value }));
const handleSave = async () => {
if (!form.title.trim()) { toast.add('Title is required', 'error'); return; }
if (!form.dimension) { toast.add('Dimension is required', 'error'); return; }
setSaving(true);
try {
await api.post(`/api/projects/${project.id}/risks`, form);
toast.add('Risk created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create risk', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 500 }}>
New Risk
);
}
function RisksTab({ project, dashboard, toast, onRefresh }) {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState('normalizedScore');
const [sortDir, setSortDir] = useState('desc');
const [page, setPage] = useState(0);
const [selectedRisk, setSelectedRisk] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState({});
const [updating, setUpdating] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const risks = useMemo(() => {
if (!dashboard?.topRisks) return [];
return sortData(dashboard.topRisks, sortKey, sortDir);
}, [dashboard, sortKey, sortDir]);
const filtered = useMemo(() => {
if (!search) return risks;
const q = search.toLowerCase();
return risks.filter(r => (r.title || '').toLowerCase().includes(q) || (r.dimension || '').toLowerCase().includes(q) || (r.ownerDisplayName || '').toLowerCase().includes(q));
}, [risks, search]);
const paged = filtered.slice(0, (page + 1) * PAGE_SIZE);
const handleSort = key => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const openDetail = risk => {
setSelectedRisk(risk);
setEditForm({ title: risk.title || '', description: risk.description || '', dimension: risk.dimension || '', domain: risk.domain || '', lifecyclePhase: risk.lifecyclePhase || '', probability: risk.probability ?? 50, impact: risk.impact ?? 50, detectability: risk.detectability ?? 50, ownerDisplayName: risk.ownerDisplayName || '' });
setEditing(false);
setConfirmDelete(null);
};
const updateStatus = async (riskId, status) => {
setUpdating(riskId);
try {
await api.patch(`/api/risks/${riskId}`, { status });
toast.add(`Risk status updated to ${status}`);
setSelectedRisk(null);
onRefresh();
} catch (e) { toast.add(e.message, 'error'); }
finally { setUpdating(null); }
};
const saveEdit = async () => {
if (!editForm.title.trim()) { toast.add('Title is required', 'error'); return; }
setUpdating('edit');
try {
await api.patch(`/api/risks/${selectedRisk.id}`, editForm);
toast.add('Risk updated');
setSelectedRisk(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to update', 'error'); }
finally { setUpdating(null); }
};
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/risks/${confirmDelete.id}`);
toast.add('Risk deleted');
setConfirmDelete(null);
setSelectedRisk(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
};
if (!dashboard) return
;
return (
<>
{[{ key: 'id', label: 'ID' }, { key: 'title', label: 'Risk' }, { key: 'dimension', label: 'Dimension' }, { key: 'lifecyclePhase', label: 'Phase' }, { key: 'normalizedScore', label: 'Score' }, { key: 'residualScore', label: 'Residual' }, { key: 'status', label: 'Status' }, { key: 'ownerDisplayName', label: 'Owner' }].map(col => (
| handleSort(col.key)}>
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
))}
{paged.map(risk => (
openDetail(risk)}>
| {risk.id?.slice(0, 8)} |
{risk.title} |
{risk.dimension} |
{risk.lifecyclePhase} |
{risk.normalizedScore} |
= 50 ? 'var(--danger)' : risk.residualScore >= 25 ? 'var(--warn)' : 'var(--accent)' }}>{risk.residualScore} |
{risk.status} |
{risk.ownerDisplayName || '-'} |
))}
{filtered.length > paged.length && (
)}
{!filtered.length &&
No risks match your search
}
{showCreate && (
setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} />
)}
{selectedRisk && !confirmDelete && (
setSelectedRisk(null)}>
e.stopPropagation()} style={{ maxWidth: 520 }}>
{selectedRisk.title}
{editing ? (
<>
setEditForm(f => ({ ...f, title: v }))} />
setEditForm(f => ({ ...f, dimension: v }))} options={DIMENSIONS} />
setEditForm(f => ({ ...f, domain: v }))} />
setEditForm(f => ({ ...f, lifecyclePhase: v }))} options={LIFECYCLE_PHASES} />
setEditForm(f => ({ ...f, probability: Number(v) }))} />
setEditForm(f => ({ ...f, impact: Number(v) }))} />
setEditForm(f => ({ ...f, detectability: Number(v) }))} />
setEditForm(f => ({ ...f, ownerDisplayName: v }))} />
>
) : (
<>
Dimension
{selectedRisk.dimension}
Phase
{selectedRisk.lifecyclePhase}
Probability
{selectedRisk.probability}
Impact
{selectedRisk.impact}
Detectability
{selectedRisk.detectability}
Score
{selectedRisk.normalizedScore}
Residual
{selectedRisk.residualScore}
Owner
{selectedRisk.ownerDisplayName || '-'}
{selectedRisk.description && (
Description
{selectedRisk.description}
)}
Update status:
{RISK_STATUSES.map(s => (
))}
>
)}
)}
{confirmDelete && (
setConfirmDelete(null)} deleting={deleting} />
)}
>
);
}
function MitigationFormModal({ project, risks, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ riskId: '', title: '', description: '', status: 'NOT_STARTED', progressPercent: 0, effectivenessPercent: 0, dueDate: '', ownerDisplayName: '' });
const set = key => value => setForm(f => ({ ...f, [key]: value }));
const handleSave = async () => {
if (!form.title.trim()) { toast.add('Title is required', 'error'); return; }
if (!form.riskId) { toast.add('Please select a risk', 'error'); return; }
setSaving(true);
try {
const body = { ...form, progressPercent: Number(form.progressPercent), effectivenessPercent: Number(form.effectivenessPercent) };
if (body.dueDate) body.dueDate = new Date(body.dueDate).toISOString();
else delete body.dueDate;
await api.post(`/api/risks/${form.riskId}/mitigations`, body);
toast.add('Mitigation created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create mitigation', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 500 }}>
New Mitigation
({ value: r.id, label: r.title }))} />
);
}
function MitigationsTab({ project, dashboard, toast, onRefresh }) {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState('progressPercent');
const [sortDir, setSortDir] = useState('asc');
const [showCreate, setShowCreate] = useState(false);
const [selectedMitigation, setSelectedMitigation] = useState(null);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState({});
const [updating, setUpdating] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const mitigations = useMemo(() => {
if (!dashboard?.mitigations) return [];
return sortData(dashboard.mitigations, sortKey, sortDir);
}, [dashboard, sortKey, sortDir]);
const risks = useMemo(() => {
if (!dashboard?.topRisks) return [];
return dashboard.topRisks;
}, [dashboard]);
const filtered = useMemo(() => {
if (!search) return mitigations;
const q = search.toLowerCase();
return mitigations.filter(m => (m.title || '').toLowerCase().includes(q) || (m.riskTitle || '').toLowerCase().includes(q));
}, [mitigations, search]);
const handleSort = key => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const openDetail = m => {
setSelectedMitigation(m);
setEditForm({ title: m.title || '', description: m.description || '', status: m.status || 'NOT_STARTED', progressPercent: m.progressPercent ?? 0, effectivenessPercent: m.effectivenessPercent ?? 0, dueDate: m.dueDate ? m.dueDate.slice(0, 10) : '', ownerDisplayName: m.ownerDisplayName || '' });
setEditing(true);
setConfirmDelete(null);
};
const saveEdit = async () => {
if (!editForm.title.trim()) { toast.add('Title is required', 'error'); return; }
setUpdating('edit');
try {
const body = { ...editForm, progressPercent: Number(editForm.progressPercent), effectivenessPercent: Number(editForm.effectivenessPercent) };
if (body.dueDate) body.dueDate = new Date(body.dueDate).toISOString();
else body.dueDate = null;
await api.patch(`/api/mitigations/${selectedMitigation.id}`, body);
toast.add('Mitigation updated');
setSelectedMitigation(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to update', 'error'); }
finally { setUpdating(null); }
};
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/mitigations/${confirmDelete.id}`);
toast.add('Mitigation deleted');
setConfirmDelete(null);
setSelectedMitigation(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
};
if (!dashboard) return ;
return (
<>
{[{ key: 'title', label: 'Title' }, { key: 'riskTitle', label: 'Risk' }, { key: 'status', label: 'Status' }, { key: 'progressPercent', label: 'Progress' }, { key: 'effectivenessPercent', label: 'Effectiveness' }, { key: 'ownerDisplayName', label: 'Owner' }].map(col => (
| handleSort(col.key)}>
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
))}
{filtered.slice(0, 50).map(m => (
openDetail(m)} style={{ cursor: 'pointer' }}>
| {m.title} |
{m.riskTitle} |
{m.status} |
= 80 ? 'var(--accent)' : (m.progressPercent || 0) >= 30 ? 'var(--warn)' : 'var(--danger)' }} />
{m.progressPercent || 0}%
|
{m.effectivenessPercent || 0}% |
{m.ownerDisplayName || '-'} |
))}
{!filtered.length && }
{showCreate && (
setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} />
)}
{selectedMitigation && !confirmDelete && (
setSelectedMitigation(null)}>
e.stopPropagation()} style={{ maxWidth: 500 }}>
{editing ? 'Edit Mitigation' : selectedMitigation.title}
setEditForm(f => ({ ...f, title: v }))} />
setEditForm(f => ({ ...f, status: v }))} options={MITIGATION_STATUSES} />
setEditForm(f => ({ ...f, progressPercent: Number(v) }))} />
setEditForm(f => ({ ...f, effectivenessPercent: Number(v) }))} />
setEditForm(f => ({ ...f, dueDate: v }))} />
setEditForm(f => ({ ...f, ownerDisplayName: v }))} />
)}
{confirmDelete && (
setConfirmDelete(null)} deleting={deleting} />
)}
>
);
}
function GateTab({ project, dashboard, toast, onRefresh }) {
const [evaluating, setEvaluating] = useState(false);
const [showDecision, setShowDecision] = useState(false);
const [decisionForm, setDecisionForm] = useState({ decision: 'APPROVED', reviewerName: '', reason: '' });
const [savingDecision, setSavingDecision] = useState(false);
const criteria = dashboard?.gate?.criteria || [];
const gateStatus = dashboard?.summary?.gateStatus || 'Unknown';
const gateId = dashboard?.gate?.id;
const gateCls = gateStatus === 'Ready' ? 'good' : gateStatus === 'Warning' ? 'warn' : 'bad';
const handleEvaluate = async () => {
setEvaluating(true);
try {
await api.post(`/api/projects/${project.id}/gate/evaluate`, {});
toast.add('Gate evaluated');
onRefresh();
} catch (e) { toast.add(e.message || 'Evaluation failed', 'error'); }
finally { setEvaluating(false); }
};
const submitDecision = async () => {
if (!decisionForm.reviewerName.trim()) { toast.add('Reviewer name is required', 'error'); return; }
setSavingDecision(true);
try {
await api.post(`/api/gates/${gateId}/decisions`, decisionForm);
toast.add('Decision submitted');
setShowDecision(false);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to submit decision', 'error'); }
finally { setSavingDecision(false); }
};
if (!dashboard) return ;
return (
<>
Deployment Gate
Gate evaluation for AI deployment readiness
| Criterion |
Required |
Actual |
Status |
{criteria.map(c => (
| {c.name} |
{c.required} |
{c.actual} |
{c.status} |
))}
{!criteria.length && No gate criteria evaluated yet
}
{gateId &&
}
History (API)
{showDecision && (
setShowDecision(false)}>
e.stopPropagation()} style={{ maxWidth: 400 }}>
Gate Decision
setDecisionForm(f => ({ ...f, decision: v }))} options={['APPROVED', 'REJECTED', 'ACCEPTED', 'NEEDS_CHANGES']} />
setDecisionForm(f => ({ ...f, reviewerName: v }))} />
)}
>
);
}
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 IntegrationFormModal({ project, dashboard, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [mode, setMode] = useState('simulated');
const [form, setForm] = useState({ provider: 'TRELLO', workspaceName: '', externalProjectKey: '', baseUrl: '', authMode: 'API_KEY', credentialsText: '' });
const set = key => value => setForm(f => ({ ...f, [key]: value }));
const wsId = dashboard?.workspace?.id || project?.workspaceId;
const handleSave = async () => {
if (!form.workspaceName.trim()) { toast.add('Workspace name is required', 'error'); return; }
let credentials = undefined;
if (mode === 'live') {
if (!form.credentialsText.trim()) { toast.add('Credentials JSON is required for live mode', 'error'); return; }
try { credentials = JSON.parse(form.credentialsText); }
catch (_error) { toast.add('Credential JSON is invalid', 'error'); return; }
}
setSaving(true);
try {
await api.post(`/api/workspaces/${wsId}/integrations`, {
provider: form.provider,
workspaceName: form.workspaceName,
externalProjectKey: form.externalProjectKey,
baseUrl: form.baseUrl,
authMode: form.authMode,
syncDirection: 'COSMIC to PM',
liveEnabled: mode === 'live',
liveConfig: form.provider === 'MICROSOFT_PLANNER' && credentials?.bucketId ? { bucketId: credentials.bucketId } : {},
credentials
});
toast.add(mode === 'live' ? 'Live integration created' : 'Simulated integration created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create integration', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 640 }}>
New Integration
{mode === 'live' && (
<>
Credentials are encrypted via AES-256-GCM and stored in OAuthToken records.
>
)}
{mode === 'simulated' && (
Simulated mode maps COSMIC risks to external work item records. No real API calls are made.
)}
);
}
function IntegrationsTab({ project, dashboard, toast, onRefresh }) {
const [showCreate, setShowCreate] = useState(false);
const [busyId, setBusyId] = useState(null);
const integrations = dashboard?.integrations || [];
const risks = dashboard?.risks || [];
const hasRisks = risks.length > 0;
const runAction = async (integration, action) => {
setBusyId(`${integration.id}:${action}`);
try {
const path = action === 'sync'
? `/api/integrations/${integration.id}/sync`
: action === 'live-test'
? `/api/integrations/${integration.id}/live/test`
: `/api/integrations/${integration.id}/live/sync`;
const result = await api.post(path, {});
const summary = result?.syncRun?.summary || result?.account || `${action} completed`;
toast.add(summary);
onRefresh();
} catch (e) { toast.add(e.message || `${action} failed`, 'error'); }
finally { setBusyId(null); }
};
const stepClass = done => done ? { color: 'var(--green)', fontWeight: 700 } : { color: 'var(--muted)' };
return (
<>
Integrations
Connect COSMIC risks to Trello, Jira, Asana, or Microsoft Planner
WORKFLOW
{[
{ label: '1. Create Project', done: !!project, detail: project?.name || '' },
{ label: '2. Assess Risks', done: hasRisks, detail: hasRisks ? `${risks.length} risk${risks.length !== 1 ? 's' : ''}` : 'No risks yet' },
{ label: '3. Configure Integration', done: integrations.length > 0, detail: integrations.length > 0 ? integrations.map(i => i.provider.replace('_', ' ')).join(', ') : 'Not configured' },
{ label: '4. Sync Risks', done: integrations.some(i => (i.mappings || []).length > 0 || (i.syncRuns || []).length > 0), detail: integrations.some(i => (i.syncRuns || []).length > 0) ? 'Sync completed' : 'Not synced' }
].map((step, i) => (
{step.done ? '✓' : i + 1}
{step.label}
{step.detail}
{i < 3 &&
→
}
))}
{['TRELLO', 'JIRA', 'ASANA', 'MICROSOFT_PLANNER'].map(provider => {
const existing = integrations.find(i => i.provider === provider);
const hasMappings = existing && (existing.mappings || []).length > 0;
const lastSync = existing?.syncRuns?.[0];
return (
{provider === 'TRELLO' ? 'T' : provider === 'JIRA' ? 'J' : provider === 'ASANA' ? 'A' : 'P'}
{provider.replace('_', ' ')}
{existing ? existing.workspaceName : 'Not configured'}
{existing && (
{existing.liveEnabled ? 'Live API' : 'Simulated Mapping'}
{hasMappings && Synced}
)}
{lastSync &&
Last: {new Date(lastSync.createdAt).toLocaleDateString()}
}
{existing ? (
<>
Manage
{existing.liveEnabled ? (
<>
>
) : (
)}
>
) : (
)}
);
})}
{showCreate && (
setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} />
)}
{!integrations.length && (
No integrations configured yet. Click "+ New Integration" to get started.
)}
>
);
}
function AuditTab({ project, toast }) {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const load = useCallback(async () => {
if (!project) return;
setLoading(true);
setError(null);
try {
const resp = await api.get(`/api/projects/${project.id}/audit`);
setEvents(resp.auditEvents || []);
} catch (e) { setError(e.message || 'Failed to load audit log'); }
finally { setLoading(false); }
}, [project]);
useEffect(() => { load(); }, [load]);
if (loading) return ;
if (error) return ;
if (!events.length) return No audit events recorded yet
;
return (
<>
Audit Log
Project activity history
| Date |
Action |
Entity |
Actor |
Details |
{events.map(e => (
| {new Date(e.createdAt).toLocaleString()} |
{e.action} |
{e.entityType} ({e.entityId?.slice(0, 8)}) |
{e.actorUserId || 'system'} |
{e.afterJson ? JSON.stringify(e.afterJson).slice(0, 80) : '-'}
|
))}
>
);
}
function ProjectsTab({ projects, workspaceId, currentProjectId, onSwitch, onRefresh, toast }) {
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [showNew, setShowNew] = useState(false);
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/projects/${confirmDelete.id}`);
toast.add(`Project "${confirmDelete.name}" deleted`);
setConfirmDelete(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to delete project', 'error'); }
finally { setDeleting(false); }
};
return (
<>
All Projects
{projects.length} project(s) in workspace
| Name |
Status |
Type |
Created |
Actions |
{[...projects].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).map(p => (
|
{p.name}
{p.id === currentProjectId ? (current) : ''}
{p.description ? {p.description} : ''}
|
{p.status} |
{p.projectType} |
{new Date(p.createdAt).toLocaleDateString()} |
{p.id !== currentProjectId && (
)}
|
))}
{!projects.length && No projects yet. Create one to get started.
}
{showNew && (
setShowNew(false)} onSaved={() => { setShowNew(false); onRefresh(); }} toast={toast} />
)}
{confirmDelete && (
setConfirmDelete(null)}>
e.stopPropagation()} style={{ maxWidth: 380 }}>
Delete "{confirmDelete.name}"?
This permanently deletes the project and all its risks, mitigations, and data.
)}
>
);
}
// ---- Indicators Tab ----
function IndicatorsTab({ project, dashboard, toast, onRefresh }) {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [showCreate, setShowCreate] = useState(false);
const [selected, setSelected] = useState(null);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState({});
const [updating, setUpdating] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const indicators = useMemo(() => {
if (!dashboard?.indicators) return [];
return sortData(dashboard.indicators, sortKey, sortDir);
}, [dashboard, sortKey, sortDir]);
const filtered = useMemo(() => {
if (!search) return indicators;
const q = search.toLowerCase();
return indicators.filter(i => (i.name || '').toLowerCase().includes(q) || (i.dimension || '').toLowerCase().includes(q));
}, [indicators, search]);
const handleSort = key => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const openDetail = item => {
setSelected(item);
setEditForm({ name: item.name || '', dimension: item.dimension || '', measurand: item.measurand || '', unit: item.unit || '', target: item.target || '', interpretationRule: item.interpretationRule || '' });
setEditing(false);
setConfirmDelete(null);
};
const saveEdit = async () => {
if (!editForm.name.trim()) { toast.add('Name is required', 'error'); return; }
if (!editForm.dimension) { toast.add('Dimension is required', 'error'); return; }
setUpdating('edit');
try {
await api.patch(`/api/projects/${project.id}/indicators/${selected.id}`, editForm);
toast.add('Indicator updated');
setSelected(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to update', 'error'); }
finally { setUpdating(null); }
};
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/projects/${project.id}/indicators/${confirmDelete.id}`);
toast.add('Indicator deleted');
setConfirmDelete(null);
setSelected(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
};
if (!dashboard) return ;
return (
<>
{[{ key: 'name', label: 'Name' }, { key: 'dimension', label: 'Dimension' }, { key: 'measurand', label: 'Measurand' }, { key: 'unit', label: 'Unit' }, { key: 'target', label: 'Target' }].map(col => (
| handleSort(col.key)}>
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
))}
{filtered.map(item => (
openDetail(item)} style={{ cursor: 'pointer' }}>
| {item.name} |
{item.dimension} |
{item.measurand || '-'} |
{item.unit || '-'} |
{item.target || '-'} |
))}
{!filtered.length && No indicators defined. Create one to track AI risk metrics.
}
{showCreate && (
setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} />
)}
{selected && !confirmDelete && (
setSelected(null)}>
e.stopPropagation()} style={{ maxWidth: 480 }}>
{selected.name}
{editing ? (
<>
setEditForm(f => ({ ...f, name: v }))} />
setEditForm(f => ({ ...f, dimension: v }))} options={DIMENSIONS} />
setEditForm(f => ({ ...f, measurand: v }))} />
setEditForm(f => ({ ...f, unit: v }))} />
>
) : (
<>
Dimension
{selected.dimension}
Measurand
{selected.measurand || '-'}
Unit
{selected.unit || '-'}
Target
{selected.target || '-'}
{selected.interpretationRule &&
Interpretation Rule
{selected.interpretationRule}
}
>
)}
)}
{confirmDelete && setConfirmDelete(null)} deleting={deleting} />}
>
);
}
function IndicatorFormModal({ project, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: '', dimension: '', measurand: '', unit: '', target: '', interpretationRule: '' });
const set = key => value => setForm(f => ({ ...f, [key]: value }));
const handleSave = async () => {
if (!form.name.trim()) { toast.add('Name is required', 'error'); return; }
if (!form.dimension) { toast.add('Dimension is required', 'error'); return; }
setSaving(true);
try {
await api.post(`/api/projects/${project.id}/indicators`, form);
toast.add('Indicator created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create indicator', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 480 }}>
New Indicator
);
}
// ---- Experiments Tab ----
function ExperimentsTab({ project, dashboard, toast, onRefresh }) {
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selected, setSelected] = useState(null);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState({});
const [updating, setUpdating] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const [showAddMetric, setShowAddMetric] = useState(null);
const [metricForm, setMetricForm] = useState({ metricName: '', metricValue: '', thresholdValue: '', status: 'unknown' });
const experiments = useMemo(() => {
if (!dashboard?.experiments) return [];
return [...dashboard.experiments].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}, [dashboard]);
const filtered = useMemo(() => {
if (!search) return experiments;
const q = search.toLowerCase();
return experiments.filter(e => (e.name || '').toLowerCase().includes(q) || (e.modelName || '').toLowerCase().includes(q));
}, [experiments, search]);
const openDetail = item => {
setSelected(item);
setEditForm({ name: item.name || '', modelName: item.modelName || '', selected: item.selected ?? false });
setEditing(false);
setConfirmDelete(null);
};
const saveEdit = async () => {
if (!editForm.name.trim()) { toast.add('Name is required', 'error'); return; }
setUpdating('edit');
try {
await api.patch(`/api/projects/${project.id}/experiments/${selected.id}`, editForm);
toast.add('Experiment updated');
setSelected(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to update', 'error'); }
finally { setUpdating(null); }
};
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/projects/${project.id}/experiments/${confirmDelete.id}`);
toast.add('Experiment deleted');
setConfirmDelete(null);
setSelected(null);
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
};
const addMetric = async (experimentId) => {
if (!metricForm.metricName.trim() || metricForm.metricValue === '') { toast.add('Metric name and value required', 'error'); return; }
setUpdating('metric');
try {
await api.post(`/api/projects/${project.id}/experiments/${experimentId}/metrics`, {
metricName: metricForm.metricName,
metricValue: Number(metricForm.metricValue),
thresholdValue: metricForm.thresholdValue ? Number(metricForm.thresholdValue) : null,
status: metricForm.status
});
toast.add('Metric added');
setShowAddMetric(null);
setMetricForm({ metricName: '', metricValue: '', thresholdValue: '', status: 'unknown' });
onRefresh();
} catch (e) { toast.add(e.message || 'Failed to add metric', 'error'); }
finally { setUpdating(null); }
};
if (!dashboard) return ;
return (
<>
{filtered.map(exp => (
openDetail(exp)}>
{exp.name}
{exp.modelName && Model: {exp.modelName}}
{exp.selected ? Selected : Draft}
{exp.metrics?.length > 0 && (
{exp.metrics.map(m => (
{m.metricName}: {m.metricValue}
{m.thresholdValue != null && / {m.thresholdValue}}
))}
)}
))}
{!filtered.length && No experiments yet. Create one to compare AI model performance.
}
{showCreate && (
setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} />
)}
{selected && !confirmDelete && (
setSelected(null)}>
e.stopPropagation()} style={{ maxWidth: 520 }}>
{selected.name}
{editing ? (
<>
setEditForm(f => ({ ...f, name: v }))} />
setEditForm(f => ({ ...f, modelName: v }))} />
>
) : (
<>
Model
{selected.modelName || '-'}
Status
{selected.selected ? 'Selected (active)' : 'Draft'}
Metrics
{(!selected.metrics || selected.metrics.length === 0) &&
No metrics recorded yet.
}
{selected.metrics?.map(m => (
{m.metricName}: {m.metricValue} {m.thresholdValue != null ? `/ ${m.thresholdValue}` : ''}
{m.status}
))}
>
)}
)}
{showAddMetric && (
setShowAddMetric(null)}>
e.stopPropagation()} style={{ maxWidth: 400 }}>
Add Metric
setMetricForm(f => ({ ...f, metricName: v }))} />
setMetricForm(f => ({ ...f, metricValue: v }))} />
setMetricForm(f => ({ ...f, thresholdValue: v }))} />
setMetricForm(f => ({ ...f, status: v }))} options={['unknown', 'pass', 'warn', 'fail']} />
)}
{confirmDelete && setConfirmDelete(null)} deleting={deleting} />}
>
);
}
function ExperimentFormModal({ project, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: '', modelName: '', selected: false });
const set = key => value => setForm(f => ({ ...f, [key]: value }));
const handleSave = async () => {
if (!form.name.trim()) { toast.add('Name is required', 'error'); return; }
setSaving(true);
try {
await api.post(`/api/projects/${project.id}/experiments`, form);
toast.add('Experiment created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create experiment', 'error'); }
finally { setSaving(false); }
};
return (
);
}
// ---- Reports Tab ----
function ReportsTab({ project, toast }) {
const [exports, setExports] = useState([]);
const [schedules, setSchedules] = useState([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(null);
const [showSchedule, setShowSchedule] = useState(false);
const [scheduleForm, setScheduleForm] = useState({ reportType: 'executive.html', format: 'html', cronExpr: '0 8 * * 1', recipients: '' });
const [savingSchedule, setSavingSchedule] = useState(false);
const loadData = useCallback(async () => {
if (!project) return;
setLoading(true);
try {
const [exp, sch] = await Promise.all([
api.get(`/api/projects/${project.id}/report-exports`),
api.get(`/api/projects/${project.id}/scheduled-reports`)
]);
setExports(exp.exports || []);
setSchedules(sch || []);
} catch (e) { /* ignore */ }
finally { setLoading(false); }
}, [project]);
useEffect(() => { loadData(); }, [loadData]);
const generateReport = async (reportType) => {
setGenerating(reportType);
try {
await api.post(`/api/projects/${project.id}/report-exports`, { reportType });
toast.add(`Report ${reportType} generated`);
loadData();
} catch (e) { toast.add(e.message || 'Failed to generate report', 'error'); }
finally { setGenerating(null); }
};
const saveSchedule = async () => {
setSavingSchedule(true);
try {
await api.post(`/api/projects/${project.id}/scheduled-reports`, {
...scheduleForm,
recipients: scheduleForm.recipients ? scheduleForm.recipients.split(',').map(s => s.trim()).filter(Boolean) : []
});
toast.add('Scheduled report created');
setShowSchedule(false);
loadData();
} catch (e) { toast.add(e.message || 'Failed to create schedule', 'error'); }
finally { setSavingSchedule(false); }
};
const deleteSchedule = async (id) => {
try {
await api.del(`/api/projects/${project.id}/scheduled-reports/${id}`);
toast.add('Schedule deleted');
loadData();
} catch (e) { toast.add(e.message || 'Failed to delete schedule', 'error'); }
};
const deleteExport = async (id) => {
try {
await api.del(`/api/projects/${project.id}/report-exports/${id}`);
toast.add('Report export deleted');
loadData();
} catch (e) { toast.add(e.message || 'Failed to delete export', 'error'); }
};
function cronToHuman(expr) {
if (!expr) return '';
const parts = expr.trim().split(/\s+/);
if (parts.length < 5) return expr;
const [min, hour, dom, month, dow] = parts;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let readable = '';
if (dow !== '*' && dom === '*' && month === '*') {
const dayNames = dow.split(',').map(d => days[parseInt(d)] || d).join(',');
readable = `${dayNames} `;
}
if (dom !== '*' && dow === '*' && month === '*') {
readable = `Day ${dom} `;
}
if (month !== '*' && month !== '*') {
// specific month
}
if (hour !== '*' && min !== '*') {
const h = parseInt(hour);
const m = parseInt(min);
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
readable += `${h12}:${m.toString().padStart(2, '0')} ${ampm}`;
}
return readable.trim() || expr;
}
if (!project) return Select a project to view reports.
;
if (loading) return ;
const reportTypes = [
{ id: 'executive.html', label: 'Executive Report (HTML)', icon: '◇' },
{ id: 'risk-register.csv', label: 'Risk Register (CSV)', icon: '⊞' },
{ id: 'full-export.csv', label: 'Full Export (CSV)', icon: '⇦' },
{ id: 'executive.json', label: 'Executive Report (JSON)', icon: '⚙' },
];
return (
<>
Reports
Generate and schedule AI risk reports
{reportTypes.map(rt => (
{rt.icon}
{rt.label}
Download
))}
Generated Reports
{exports.length === 0 ? (
No reports generated yet.
) : (
| Type |
Format |
Status |
Generated |
|
{exports.slice(0, 20).map(exp => (
| {exp.reportType} |
{exp.format} |
{exp.status} |
{new Date(exp.createdAt).toLocaleString()} |
View
|
))}
)}
Scheduled Reports
{schedules.length === 0 ? (
No scheduled reports yet.
) : (
| Type |
Format |
Cron |
Enabled |
Last Run |
|
{schedules.map(s => (
| {s.reportType} |
{s.format} |
{cronToHuman(s.cronExpr) ? <>{cronToHuman(s.cronExpr)} {s.cronExpr}> : s.cronExpr}
|
{s.enabled ? '✓' : '✗'} |
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '-'} |
|
))}
)}
{showSchedule && (
setShowSchedule(false)}>
e.stopPropagation()} style={{ maxWidth: 440 }}>
Schedule Report
setScheduleForm(f => ({ ...f, reportType: v }))} options={['executive.html', 'risk-register.csv', 'full-export.csv', 'executive.json']} />
setScheduleForm(f => ({ ...f, format: v }))} options={['html', 'csv', 'json']} />
setScheduleForm(f => ({ ...f, cronExpr: v }))} />
)}
>
);
}
function ProjectSettingsModal({ project, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: project?.name || '', description: project?.description || '', subtitle: project?.subtitle || '', status: project?.status || 'Active' });
const handleSave = async () => {
if (!form.name.trim()) { toast.add('Project name is required', 'error'); return; }
setSaving(true);
try {
await api.patch(`/api/projects/${project.id}`, form);
toast.add('Project updated');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to update project', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 450 }}>
Project Settings
setForm(f => ({ ...f, name: v }))} />
setForm(f => ({ ...f, subtitle: v }))} />
setForm(f => ({ ...f, status: v }))} options={['Active', 'Archived', 'On Hold']} />
);
}
function NewProjectModal({ workspaceId, onClose, onSaved, toast }) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: '', description: '', projectType: 'AI-Enabler' });
const handleSave = async () => {
if (!form.name.trim()) { toast.add('Project name is required', 'error'); return; }
setSaving(true);
try {
await api.post(`/api/workspaces/${workspaceId}/projects`, form);
toast.add('Project created');
onSaved();
} catch (e) { toast.add(e.message || 'Failed to create project', 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 450 }}>
New Project
setForm(f => ({ ...f, name: v }))} />
setForm(f => ({ ...f, projectType: v }))} options={['AI-Enabler', 'GENERATIVE_AI', 'ML-Pipeline', 'Automation']} />
);
}
function ProjectManagerModal({ projects, workspaceId, currentProjectId, onClose, onSwitch, onSaved, toast }) {
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [showNew, setShowNew] = useState(false);
const handleDelete = async () => {
setDeleting(true);
try {
await api.del(`/api/projects/${confirmDelete.id}`);
toast.add(`Project "${confirmDelete.name}" deleted`);
setConfirmDelete(null);
onSaved();
} catch (e) { toast.add(e.message || 'Failed to delete project', 'error'); }
finally { setDeleting(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 700, maxHeight: '80vh', overflow: 'auto' }}>
All Projects
{projects.length} project(s) in workspace
| Name |
Status |
Type |
Created |
|
{[...projects].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).map(p => (
| {p.name}{p.id === currentProjectId ? (current) : ''} |
{p.status} |
{p.projectType} |
{new Date(p.createdAt).toLocaleDateString()} |
{p.id !== currentProjectId && (
)}
|
))}
{!projects.length &&
}
{confirmDelete && (
setConfirmDelete(null)}>
e.stopPropagation()} style={{ maxWidth: 380 }}>
Delete "{confirmDelete.name}"?
This permanently deletes the project and all its risks, mitigations, and data.
)}
{showNew && (
setShowNew(false)} onSaved={() => { setShowNew(false); onSaved(); }} toast={toast} />
)}
);
}
function MembersTab({ organizationId, toast, sessionUserId }) {
const [members, setMembers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const [showInvite, setShowInvite] = useState(false);
const [editingMember, setEditingMember] = useState(null);
const [editRoleId, setEditRoleId] = useState('');
const loadMembers = useCallback(async () => {
if (!organizationId) return;
setLoading(true);
try {
const [data, rolesData] = await Promise.all([
api.get(`/api/organizations/${organizationId}/members`),
api.get(`/api/roles`)
]);
setMembers((data.members || []).filter(m => m.user?.platformRole === 'NONE' || !m.user?.platformRole));
setRoles(rolesData || []);
} catch (e) {
toast.add(e.message || 'Failed to load members', 'error');
} finally {
setLoading(false);
}
}, [organizationId, toast]);
useEffect(() => { loadMembers(); }, [loadMembers]);
const startEdit = (m) => {
setEditingMember(m);
setEditRoleId(m.role?.id || '');
};
const saveRole = async () => {
try {
await api.patch(`/api/organizations/${organizationId}/members/${editingMember.id}`, { roleId: editRoleId });
toast.add('Role updated', 'success');
setEditingMember(null);
loadMembers();
} catch (e) {
toast.add(e.message || 'Failed to update role', 'error');
}
};
const toggleStatus = async (m) => {
const newStatus = m.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
try {
await api.patch(`/api/organizations/${organizationId}/members/${m.id}`, { status: newStatus });
toast.add(`Member ${newStatus === 'ACTIVE' ? 'enabled' : 'disabled'}`, 'success');
loadMembers();
} catch (e) {
toast.add(e.message || 'Failed to update member', 'error');
}
};
const removeMember = async (m) => {
if (!window.confirm(`Remove ${m.user?.email || 'this member'} from the organization?`)) return;
try {
await api.del(`/api/organizations/${organizationId}/members/${m.id}`);
toast.add('Member removed', 'success');
loadMembers();
} catch (e) {
toast.add(e.message || 'Failed to remove member', 'error');
}
};
return (
<>
Members
{members.length} member(s) in organization
{loading ? (
) : (
| Name |
Email |
Role |
Status |
Actions |
{members.map(m => (
| {m.user?.displayName || m.user?.email} |
{m.user?.email} |
{editingMember?.id === m.id ? (
) : (
{m.role?.name || m.roleCode}
)}
|
{m.status} |
{editingMember?.id === m.id ? (
<>
>
) : (
<>
>
)}
|
))}
)}
{showInvite && (
setShowInvite(false)} onSaved={() => { setShowInvite(false); loadMembers(); }} toast={toast} />
)}
{/* Small style for danger button */}
>
);
}
function InviteFormModal({ organizationId, onClose, onSaved, toast }) {
const [email, setEmail] = useState('');
const [roleCode, setRoleCode] = useState('viewer');
const [saving, setSaving] = useState(false);
const [inviteLink, setInviteLink] = useState('');
const handleSave = async () => {
if (!email.trim()) { toast.add('Email is required', 'error'); return; }
setSaving(true);
setInviteLink('');
try {
const data = await api.post('/api/auth/invite', { organizationId, email: email.trim(), roleCode });
const baseUrl = window.location.origin;
const link = `${baseUrl}/accept-invite?token=${data.token}`;
setInviteLink(link);
toast.add(`Invitation created for ${email}`);
onSaved();
} catch (e) {
toast.add(e.message || 'Failed to create invitation', 'error');
} finally {
setSaving(false);
}
};
const copyLink = () => {
navigator.clipboard.writeText(inviteLink).then(() => toast.add('Invite link copied'));
};
return (
e.stopPropagation()} style={{ maxWidth: 480 }}>
Invite Member
{inviteLink ? (
<>
Share this link with the user:
>
) : (
<>
>
)}
);
}
function AdminTab({ platformRole, toast }) {
const isSuper = platformRole === 'SUPER_ADMIN' || platformRole === 'SUPER_OWNER';
const isOwner = platformRole === 'SUPER_OWNER';
const [users, setUsers] = useState([]);
const [orgs, setOrgs] = useState([]);
const [admins, setAdmins] = useState([]);
const [userQuery, setUserQuery] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [assigning, setAssigning] = useState(null); // {adminId} or null
const loadData = useCallback(async () => {
if (!isSuper) return;
setLoading(true);
setError(null);
try {
const [userResp, orgResp, adminResp] = await Promise.all([
api.get('/api/admin/users'),
api.get('/api/admin/organizations'),
api.get('/api/admin/super-admins')
]);
setUsers(userResp.users || []);
setOrgs(orgResp.organizations || []);
setAdmins(adminResp.admins || []);
} catch (e) {
setError(e.message || 'Failed to load admin data');
} finally {
setLoading(false);
}
}, [isSuper]);
useEffect(() => { loadData(); }, [loadData]);
const promoteToSuperAdmin = async (userId) => {
const user = users.find(u => u.id === userId);
if (!user) return;
const name = prompt(`Promote "${user.displayName}" to Super Admin? Enter display name:`, user.displayName);
if (!name) return;
try {
await api.post('/api/admin/super-admins', { email: user.email, displayName: name });
toast.add('Super Admin created');
loadData();
} catch (e) { toast.add(e.message || 'Failed', 'error'); }
};
const demoteSuperAdmin = async (adminId) => {
if (!window.confirm('Remove this Super Admin?')) return;
try {
await api.del(`/api/admin/super-admins/${adminId}`);
toast.add('Super Admin removed');
loadData();
} catch (e) { toast.add(e.message || 'Failed', 'error'); }
};
if (!isSuper) return Access denied. Super Admin or Super Owner role required.
;
if (loading) return ;
return (
<>
Platform Admin
Manage users, organizations, and platform administrators
{/* Super Admins section (Super Owner only) */}
{isOwner && (
Super Admins
Platform administrators who can manage organizations they are assigned to. Only Super Owner can add/remove and manage org access.
{admins.filter(a => a.platformRole === 'SUPER_ADMIN').length === 0 ? (
No Super Admins yet. Promote users below.
) : (
| Name | Email | Role | Assigned Orgs | Actions |
{admins.filter(a => a.platformRole === 'SUPER_ADMIN').map(a => (
| {a.displayName} |
{a.email} |
Super Admin |
{a.organizationAccess && a.organizationAccess.length > 0 ? (
{a.organizationAccess.map(oa => oa.organization?.name || oa.organizationId).join(', ')}
) : (
None
)}
|
|
))}
)}
{assigning && (
Assign Organization Access
Currently assigned:
{admins.find(a => a.id === assigning)?.organizationAccess?.map(oa => (
{oa.organization?.name || oa.organizationId}
)) ||
None}
)}
)}
{/* Users */}
Users ({users.length})
| Name | Email | Platform Role | Created | Actions |
{users.filter(u => {
if (!userQuery) return true;
const q = userQuery.toLowerCase();
return u.email.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q);
}).map(u => (
| {u.displayName} |
{u.email} |
{u.platformRole || 'NONE'} |
{new Date(u.createdAt).toLocaleDateString()} |
{u.platformRole === 'NONE' && isOwner && (
)}
|
))}
{/* Organizations */}
All Organizations ({orgs.length})
| Name | Slug | Status | Members | Workspaces | Created |
{orgs.map(o => (
| {o.name} |
{o.slug} |
{o.status} |
{o._count?.memberships || 0} |
{o._count?.workspaces || 0} |
{new Date(o.createdAt).toLocaleDateString()} |
))}
{!orgs.length &&
}
>
);
}
export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [project, setProject] = useState(null);
const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState(searchParams.get('projectId') || null);
const [dashboard, setDashboard] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
const [showProjectSettings, setShowProjectSettings] = useState(false);
const [showProjectManager, setShowProjectManager] = useState(false);
const [workspaceId, setWorkspaceId] = useState(null);
const [organizationId, setOrganizationId] = useState(null);
const [sessionUserId, setSessionUserId] = useState(null);
const [platformRole, setPlatformRole] = useState('NONE');
const isSuper = platformRole === 'SUPER_ADMIN' || platformRole === 'SUPER_OWNER';
const VISIBLE_TABS = isSuper ? ['Admin'] : ORG_TABS;
const tab = VISIBLE_TABS.includes(searchParams.get('tab')) ? searchParams.get('tab') : VISIBLE_TABS[0];
const { toasts, add } = useToast();
const onRefresh = useCallback(() => setRetryCount(c => c + 1), []);
const loadDashboard = useCallback(async (projectId) => {
setLoading(true);
setError(null);
try {
const session = await api.get('/api/auth/session');
if (!session?.authenticated) { setError('Not authenticated.'); setLoading(false); return; }
setSessionUserId(session.user?.id || null);
setPlatformRole(session.user?.platformRole || 'NONE');
const isSuperUser = session.user?.platformRole === 'SUPER_ADMIN' || session.user?.platformRole === 'SUPER_OWNER';
// Super users have no org membership — skip org data loading
if (isSuperUser) {
setLoading(false);
return;
}
const membership = session.memberships?.[0];
if (!membership) { setError('No organization membership found.'); setLoading(false); return; }
const orgId = membership.organizationId;
setOrganizationId(orgId);
const workspacesResp = await api.get(`/api/workspaces?organizationId=${orgId}`);
const ws = workspacesResp.workspaces?.[0];
if (!ws) { setError('No workspace found — create one to get started.'); setLoading(false); return; }
setWorkspaceId(ws.id);
const projectsResp = await api.get(`/api/workspaces/${ws.id}/projects`);
const allProjects = projectsResp.projects || [];
if (!allProjects.length) { setError('No project found — create a project to get started.'); setLoading(false); return; }
setProjects(allProjects);
let proj = projectId ? allProjects.find(p => p.id === projectId) : null;
if (!proj) proj = allProjects[0];
if (proj.id !== (searchParams.get('projectId') || null)) {
const currentTab = searchParams.get('tab') || 'Overview';
router.replace(`/dashboard?tab=${currentTab}&projectId=${proj.id}`);
}
setProject(proj);
const dashResp = await api.get(`/api/projects/${proj.id}/dashboard`);
setDashboard(dashResp.dashboard);
setLoading(false);
} catch (e) {
setError(e.message || 'Failed to load dashboard data');
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { loadDashboard(selectedProjectId); }, [loadDashboard, retryCount, selectedProjectId]);
const switchProject = pid => {
setSelectedProjectId(pid);
router.push(`/dashboard?tab=${tab}&projectId=${pid}`);
};
const [showCreateOnError, setShowCreateOnError] = useState(false);
if (loading && tab !== 'Integrations') return ;
if (error && tab !== 'Integrations') return (
!
{error}
{error.includes('No project found') && workspaceId && (
)}
{showCreateOnError && workspaceId && (
setShowCreateOnError(false)} onSaved={() => { setShowCreateOnError(false); setRetryCount(c => c + 1); }} toast={{ add }} />
)}
);
return (
<>
{!isSuper && (
{project?.subtitle || project?.description || ''}
)}
{VISIBLE_TABS.map(t => (
))}
{tab === 'Overview' && !isSuper && }
{tab === 'Projects' && !isSuper && }
{tab === 'Risks' && !isSuper && }
{tab === 'Mitigations' && !isSuper && }
{tab === 'Gate' && !isSuper && }
{tab === 'Indicators' && !isSuper && }
{tab === 'Experiments' && !isSuper && }
{tab === 'Reports' && !isSuper && }
{tab === 'Integrations' && !isSuper && }
{tab === 'Audit' && !isSuper && }
{tab === 'Members' && !isSuper && }
{tab === 'Admin' && }
{showProjectSettings && (
setShowProjectSettings(false)} onSaved={() => { setShowProjectSettings(false); onRefresh(); }} toast={{ add }} />
)}
{showProjectManager && (
setShowProjectManager(false)}
onSwitch={switchProject}
onSaved={() => { setShowProjectManager(false); onRefresh(); }}
toast={{ add }}
/>
)}
>
);
}