feat: initial TrustOS platform scaffold
- FastAPI backend: auth, findings, dashboard, attack paths, footprint, AI translator, risk calculator, PDF report generator - Next.js frontend: Vault dashboard, login, findings table, finding detail with AI coach, digital footprint, reports - PostgreSQL data model: tenants, users, assets, findings, risk scores, audit reports, attack paths - Docker Compose + Dockerfiles for all services - Demo seed data: Acme Corp with 6 findings and 90-day risk score history - AI Risk Translator (OpenAI/Anthropic) with plain-English business impact - Role-based access: executive / it_admin / trustos_admin - Scope-lock engine: authorization required before any assessment Stage 1-8 complete: Phase 1 Vault Audit product ready
This commit is contained in:
164
frontend/src/app/dashboard/page.tsx
Normal file
164
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type DashboardData } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import RiskDial from "@/components/RiskDial";
|
||||
import TopRiskCard from "@/components/TopRiskCard";
|
||||
import ScoreTrend from "@/components/ScoreTrend";
|
||||
import { TrendingUp, TrendingDown, Minus, AlertCircle, AlertTriangle, Activity, Calendar } from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
if (!tenantId) { router.replace("/login"); return; }
|
||||
api.dashboard(tenantId)
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId]);
|
||||
|
||||
const DeltaIcon = !data?.score_delta ? Minus : data.score_delta > 0 ? TrendingUp : TrendingDown;
|
||||
const deltaColor = !data?.score_delta ? "text-vault-muted" : data.score_delta > 0 ? "text-emerald-400" : "text-red-400";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-vault-muted text-sm mb-1">
|
||||
{data?.tenant_name ?? "Loading..."} · Vault Dashboard
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-vault-text">Cyber Resilience Overview</h1>
|
||||
</div>
|
||||
{data?.baseline_date && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-vault-surface border border-vault-border text-xs text-vault-muted">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Audit baseline: {new Date(data.baseline_date).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="vault-card border-vault-crimson/40 bg-vault-crimsonDim/30 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* Top row: score + stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-8">
|
||||
{/* Risk Dial */}
|
||||
<div className="vault-card flex flex-col items-center justify-center lg:col-span-1">
|
||||
<RiskDial score={data.current_score} size={180} />
|
||||
{data.score_delta !== null && (
|
||||
<div className={`flex items-center gap-1.5 mt-3 text-sm font-semibold ${deltaColor}`}>
|
||||
<DeltaIcon className="w-4 h-4" />
|
||||
{data.score_delta > 0 ? "+" : ""}{data.score_delta?.toFixed(1)} pts this month
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="lg:col-span-3 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
label: "Critical Issues",
|
||||
value: data.open_critical,
|
||||
icon: AlertCircle,
|
||||
color: data.open_critical > 0 ? "text-red-400" : "text-emerald-400",
|
||||
bg: data.open_critical > 0 ? "bg-vault-crimsonDim/40 border-vault-crimson/30" : "bg-vault-emeraldDim/40 border-green-800/30",
|
||||
},
|
||||
{
|
||||
label: "High Issues",
|
||||
value: data.open_high,
|
||||
icon: AlertTriangle,
|
||||
color: data.open_high > 0 ? "text-orange-400" : "text-emerald-400",
|
||||
bg: "bg-vault-surface",
|
||||
},
|
||||
{
|
||||
label: "Medium Issues",
|
||||
value: data.open_medium,
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-vault-surface",
|
||||
},
|
||||
{
|
||||
label: "Total Open",
|
||||
value: data.total_open,
|
||||
icon: Activity,
|
||||
color: "text-vault-text",
|
||||
bg: "bg-vault-surface",
|
||||
},
|
||||
].map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className={`vault-card ${bg} flex flex-col`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-vault-muted text-xs font-medium">{label}</p>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
<p className={`text-3xl font-bold ${color}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score trend */}
|
||||
<div className="vault-card mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-vault-text font-semibold">Risk Score — 90 Day Trend</h2>
|
||||
<span className="text-xs text-vault-muted">Higher is safer · 100 = optimal</span>
|
||||
</div>
|
||||
{data.score_trend.length > 0 ? (
|
||||
<ScoreTrend data={data.score_trend} />
|
||||
) : (
|
||||
<p className="text-vault-muted text-sm text-center py-8">No trend data yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 3 Risks */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-vault-text font-semibold">
|
||||
{role === "executive" ? "Top Risks Requiring Your Attention" : "Top Risks"}
|
||||
</h2>
|
||||
<a href="/findings" className="text-vault-sapphireLight text-xs hover:underline">
|
||||
View all findings →
|
||||
</a>
|
||||
</div>
|
||||
{data.top_risks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
|
||||
{data.top_risks.map((risk, i) => (
|
||||
<TopRiskCard key={risk.id} risk={risk} index={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vault-card text-center py-12 border-vault-emerald/20 bg-vault-emeraldDim/20">
|
||||
<p className="text-emerald-400 font-semibold">✓ No active top risks flagged</p>
|
||||
<p className="text-vault-muted text-sm mt-1">Continue monitoring to stay ahead of emerging threats</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
245
frontend/src/app/findings/[id]/page.tsx
Normal file
245
frontend/src/app/findings/[id]/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type Finding, type AttackPath } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { ArrowLeft, MessageSquare, Send, GitBranch } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function FindingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { role, ready } = useAuth();
|
||||
const [finding, setFinding] = useState<Finding | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [attackPaths, setAttackPaths] = useState<AttackPath[]>([]);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [resolveNote, setResolveNote] = useState("");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !id) return;
|
||||
api.finding(id).then(f => {
|
||||
setFinding(f);
|
||||
return api.attackPaths(id).then(setAttackPaths).catch(() => {});
|
||||
}).finally(() => setLoading(false));
|
||||
}, [ready, id]);
|
||||
|
||||
async function askCoach() {
|
||||
if (!question.trim() || !id) return;
|
||||
setAsking(true);
|
||||
try {
|
||||
const r = await api.aiExplain(id, question);
|
||||
setAnswer(r.answer);
|
||||
} catch {
|
||||
setAnswer("AI coach is unavailable. Please check your API key configuration.");
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function markResolved() {
|
||||
if (!finding || !resolveNote.trim()) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const updated = await api.updateFindingStatus(finding.id, { status: "resolved", resolution_note: resolveNote });
|
||||
setFinding(updated);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!finding) return null;
|
||||
|
||||
const isExecutive = role === "executive";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8 max-w-4xl">
|
||||
<Link href="/findings" className="inline-flex items-center gap-1.5 text-vault-muted text-sm hover:text-vault-text mb-6">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Findings
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="vault-card mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap mb-2">
|
||||
<span className={`vault-badge-${finding.severity}`}>{finding.severity.toUpperCase()}</span>
|
||||
<span className="vault-badge-info">{finding.status.replace("_", " ")}</span>
|
||||
{finding.is_top_risk && (
|
||||
<span className="vault-badge-critical">⭐ Top Risk</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-vault-text leading-tight">{finding.title}</h1>
|
||||
{finding.affected_component && (
|
||||
<p className="text-vault-muted text-sm mt-1">📍 {finding.affected_component}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Executive (AI) view */}
|
||||
<div className="vault-card">
|
||||
<h2 className="text-vault-sapphireLight text-sm font-semibold uppercase tracking-wider mb-4">
|
||||
Business Impact
|
||||
</h2>
|
||||
{finding.ai_summary && (
|
||||
<div className="mb-4">
|
||||
<p className="text-vault-text leading-relaxed">{finding.ai_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
{finding.ai_business_impact && (
|
||||
<div className="bg-vault-dark rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-vault-muted font-medium mb-1">Why it matters</p>
|
||||
<p className="text-vault-subtle text-sm leading-relaxed">{finding.ai_business_impact}</p>
|
||||
</div>
|
||||
)}
|
||||
{finding.ai_remediation_steps && (
|
||||
<div>
|
||||
<p className="text-xs text-vault-muted font-medium mb-2">Remediation Steps</p>
|
||||
<pre className="text-vault-subtle text-xs leading-relaxed whitespace-pre-wrap font-sans">
|
||||
{finding.ai_remediation_steps}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical view (IT only) */}
|
||||
{!isExecutive && (
|
||||
<div className="vault-card">
|
||||
<h2 className="text-vault-muted text-sm font-semibold uppercase tracking-wider mb-4">
|
||||
Technical Details
|
||||
</h2>
|
||||
{finding.cve_id && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs text-vault-muted">CVE:</span>
|
||||
<code className="text-vault-sapphireLight text-xs bg-vault-sapphireDim px-2 py-0.5 rounded">
|
||||
{finding.cve_id}
|
||||
</code>
|
||||
{finding.cvss_score && (
|
||||
<span className="text-vault-muted text-xs">CVSS {finding.cvss_score}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{finding.technical_description && (
|
||||
<p className="text-vault-subtle text-sm leading-relaxed mb-3">{finding.technical_description}</p>
|
||||
)}
|
||||
{finding.source && (
|
||||
<p className="text-vault-muted text-xs">Source: <span className="text-vault-subtle">{finding.source}</span></p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attack Path */}
|
||||
{attackPaths.length > 0 && (
|
||||
<div className="vault-card mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 text-vault-sapphire" />
|
||||
<h2 className="text-vault-text font-semibold">Attack Path</h2>
|
||||
</div>
|
||||
{attackPaths[0].ai_narrative && (
|
||||
<p className="text-vault-subtle text-sm leading-relaxed mb-4">{attackPaths[0].ai_narrative}</p>
|
||||
)}
|
||||
{attackPaths[0].nodes_json && (() => {
|
||||
try {
|
||||
const nodes = JSON.parse(attackPaths[0].nodes_json);
|
||||
const nodeColors: Record<string, string> = {
|
||||
attacker: "bg-red-900/40 text-red-300 border-red-800/50",
|
||||
entry_point: "bg-orange-900/40 text-orange-300 border-orange-800/50",
|
||||
pivot: "bg-amber-900/40 text-amber-300 border-amber-800/50",
|
||||
target: "bg-blue-900/40 text-blue-300 border-blue-800/50",
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nodes.map((n: any, i: number) => (
|
||||
<div key={n.id} className="flex items-center gap-2">
|
||||
<div className={`px-3 py-1.5 rounded-lg border text-xs font-medium ${nodeColors[n.type] ?? "bg-vault-dark border-vault-border text-vault-muted"}`}>
|
||||
{n.label}
|
||||
</div>
|
||||
{i < nodes.length - 1 && <span className="text-vault-muted">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} catch { return null; }
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Security Coach */}
|
||||
<div className="vault-card mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MessageSquare className="w-4 h-4 text-vault-sapphire" />
|
||||
<h2 className="text-vault-text font-semibold">AI Security Coach</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && askCoach()}
|
||||
placeholder="Ask a question… e.g. 'Can ransomware use this?' or 'What is the estimated cost?'"
|
||||
className="flex-1 px-3.5 py-2 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire transition-colors"
|
||||
/>
|
||||
<button onClick={askCoach} disabled={asking || !question.trim()} className="btn-primary px-3">
|
||||
{asking ? <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{answer && (
|
||||
<div className="bg-vault-dark rounded-lg p-4 border border-vault-sapphireDim">
|
||||
<p className="text-xs text-vault-sapphireLight font-medium mb-2">TrustOS AI</p>
|
||||
<p className="text-vault-subtle text-sm leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap mt-3">
|
||||
{["Why does this matter to our business?", "Can ransomware use this?", "How would an attacker exploit this?", "What is the estimated cost if exploited?"].map(q => (
|
||||
<button key={q} onClick={() => { setQuestion(q); }} className="text-xs px-2.5 py-1 rounded-full border border-vault-border text-vault-muted hover:text-vault-text hover:border-vault-sapphire transition-colors">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remediation — IT only */}
|
||||
{!isExecutive && finding.status === "open" && (
|
||||
<div className="vault-card">
|
||||
<h2 className="text-vault-text font-semibold mb-4">Mark as Resolved</h2>
|
||||
<textarea
|
||||
value={resolveNote}
|
||||
onChange={e => setResolveNote(e.target.value)}
|
||||
placeholder="Describe how this was resolved (required)…"
|
||||
rows={3}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire resize-none transition-colors mb-3"
|
||||
/>
|
||||
<button onClick={markResolved} disabled={!resolveNote.trim() || updating} className="btn-primary">
|
||||
{updating ? "Saving..." : "Mark Resolved"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{finding.status === "resolved" && (
|
||||
<div className="vault-card border-green-800/30 bg-vault-emeraldDim/20">
|
||||
<p className="text-emerald-400 text-sm font-semibold">✓ Marked as resolved</p>
|
||||
{finding.resolution_note && (
|
||||
<p className="text-vault-subtle text-sm mt-1">{finding.resolution_note}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/app/findings/page.tsx
Normal file
168
frontend/src/app/findings/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type Finding } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Link from "next/link";
|
||||
import { Shield, Filter, ArrowUpDown, CheckCircle2, Clock, AlertCircle } from "lucide-react";
|
||||
|
||||
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: "vault-badge-critical",
|
||||
in_progress: "vault-badge-medium",
|
||||
resolved: "vault-badge-low",
|
||||
verified: "vault-badge-info",
|
||||
accepted_risk: "vault-badge-info",
|
||||
};
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
external_exposure: "External Exposure",
|
||||
cloud_posture: "Cloud Posture",
|
||||
credential_exposure: "Credential Exposure",
|
||||
digital_footprint: "Digital Footprint",
|
||||
web_application: "Web Application",
|
||||
network: "Network",
|
||||
identity: "Identity",
|
||||
third_party: "Third Party",
|
||||
compliance: "Compliance",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export default function FindingsPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [findings, setFindings] = useState<Finding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterSeverity, setFilterSeverity] = useState("all");
|
||||
const [filterStatus, setFilterStatus] = useState("open");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId) return;
|
||||
const params = [
|
||||
filterSeverity !== "all" ? `severity=${filterSeverity}` : "",
|
||||
filterStatus !== "all" ? `status=${filterStatus}` : "",
|
||||
].filter(Boolean).join("&");
|
||||
api.findings(tenantId, params)
|
||||
.then(setFindings)
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId, filterSeverity, filterStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-vault-sapphire" />
|
||||
Findings
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-0.5">All security findings across your environment</p>
|
||||
</div>
|
||||
<span className="text-vault-muted text-sm">{findings.length} results</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-6 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-vault-muted" />
|
||||
<span className="text-vault-muted text-sm">Filter:</span>
|
||||
</div>
|
||||
{["all", "critical", "high", "medium", "low"].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setFilterSeverity(s); setLoading(true); }}
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
|
||||
filterSeverity === s
|
||||
? "bg-vault-sapphire text-white border-vault-sapphire"
|
||||
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "All Severities" : s.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-l border-vault-border mx-1" />
|
||||
{["all", "open", "in_progress", "resolved", "verified"].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setFilterStatus(s); setLoading(true); }}
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
|
||||
filterStatus === s
|
||||
? "bg-vault-sapphire text-white border-vault-sapphire"
|
||||
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "All Statuses" : s.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
<div className="vault-card overflow-hidden p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : findings.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-400 mx-auto mb-3" />
|
||||
<p className="text-vault-text font-semibold">No findings match these filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-vault-border">
|
||||
{["Severity", "Title", "Category", "Status", "Priority", ""].map(h => (
|
||||
<th key={h} className="px-5 py-3 text-left text-xs font-semibold text-vault-muted uppercase tracking-wider">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{findings.map(f => (
|
||||
<tr key={f.id} className="border-b border-vault-border/50 hover:bg-vault-titanium/30 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<span className={`vault-badge-${f.severity}`}>{f.severity.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-vault-text text-sm font-medium leading-snug max-w-md">{f.title}</p>
|
||||
{f.ai_summary && (
|
||||
<p className="text-vault-muted text-xs mt-0.5 line-clamp-1">{f.ai_summary}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-vault-muted text-xs whitespace-nowrap">
|
||||
{CATEGORY_LABELS[f.category] ?? f.category}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={STATUS_COLORS[f.status] ?? "vault-badge-info"}>
|
||||
{f.status.replace("_", " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{f.ai_fix_priority && (
|
||||
<span className={
|
||||
f.ai_fix_priority === "urgent" ? "text-red-400 text-xs font-semibold" :
|
||||
f.ai_fix_priority === "soon" ? "text-amber-400 text-xs font-semibold" :
|
||||
"text-vault-muted text-xs"
|
||||
}>
|
||||
{f.ai_fix_priority}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/findings/${f.id}`}
|
||||
className="text-vault-sapphireLight text-xs hover:underline whitespace-nowrap"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
frontend/src/app/footprint/page.tsx
Normal file
103
frontend/src/app/footprint/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type FootprintData } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { Search, User, Shield, AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FootprintPage() {
|
||||
const { tenantId, ready } = useAuth();
|
||||
const [data, setData] = useState<FootprintData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId) return;
|
||||
api.footprint(tenantId).then(setData).finally(() => setLoading(false));
|
||||
}, [ready, tenantId]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
||||
<Search className="w-6 h-6 text-vault-sapphire" />
|
||||
Digital Footprint Center
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">
|
||||
Publicly available organizational exposure — authorized scope only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : data && (
|
||||
<>
|
||||
{/* Executive Exposure */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-vault-sapphire" />
|
||||
Executive Exposure
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.executives.map(exec => (
|
||||
<div key={exec.id} className="vault-card hover:bg-vault-titanium/50 transition-colors">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-full bg-vault-sapphire/20 border border-vault-sapphire/30 flex items-center justify-center">
|
||||
<span className="text-vault-sapphireLight font-bold text-sm">
|
||||
{exec.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-vault-text font-medium text-sm">{exec.name}</p>
|
||||
<p className="text-vault-muted text-xs">{exec.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-vault-muted space-y-1">
|
||||
<p>📧 {exec.email || "No corporate email enrolled"}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.executives.length === 0 && (
|
||||
<p className="text-vault-muted text-sm col-span-3">No executives enrolled. Contact your TrustOS administrator.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footprint Findings */}
|
||||
<div>
|
||||
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-vault-sapphire" />
|
||||
Exposure Findings
|
||||
<span className="ml-2 vault-badge-high">{data.total_exposures}</span>
|
||||
</h2>
|
||||
{data.footprint_findings.length === 0 ? (
|
||||
<div className="vault-card text-center py-10">
|
||||
<p className="text-vault-muted text-sm">No digital footprint findings recorded yet.</p>
|
||||
<p className="text-vault-muted text-xs mt-1">Findings will appear here after a Vault Audit is completed.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.footprint_findings.map((f: any) => (
|
||||
<div key={f.id} className="vault-card flex items-start gap-4">
|
||||
<span className={`vault-badge-${f.severity} flex-shrink-0 mt-0.5`}>{f.severity.toUpperCase()}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-vault-text text-sm font-medium">{f.title}</p>
|
||||
{f.ai_summary && (
|
||||
<p className="text-vault-muted text-xs mt-1">{f.ai_summary}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="vault-badge-info text-xs flex-shrink-0">{f.status.replace("_", " ")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/app/globals.css
Normal file
52
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-inter: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: #0a0d14;
|
||||
color: #e2e8f0;
|
||||
font-family: var(--font-inter);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1a1f2e; }
|
||||
::-webkit-scrollbar-thumb { background: #2d3447; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3b82d4; }
|
||||
|
||||
@layer components {
|
||||
.vault-card {
|
||||
@apply bg-vault-surface border border-vault-border rounded-xl p-6;
|
||||
}
|
||||
.vault-badge-critical {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-900/40 text-red-300 border border-red-800/50;
|
||||
}
|
||||
.vault-badge-high {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-orange-900/40 text-orange-300 border border-orange-800/50;
|
||||
}
|
||||
.vault-badge-medium {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-900/40 text-amber-300 border border-amber-800/50;
|
||||
}
|
||||
.vault-badge-low {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-900/40 text-green-300 border border-green-800/50;
|
||||
}
|
||||
.vault-badge-info {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-900/40 text-blue-300 border border-blue-800/50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-vault-sapphire text-white text-sm font-medium hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg text-vault-subtle text-sm font-medium hover:bg-vault-titanium hover:text-vault-text transition-colors;
|
||||
}
|
||||
}
|
||||
21
frontend/src/app/layout.tsx
Normal file
21
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TrustOS — The AI Operating System for Cyber Resilience",
|
||||
description: "Understand, reduce, and prove cyber risk. Continuous AI-powered resilience for growing companies.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="bg-vault-black min-h-screen">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
131
frontend/src/app/login/page.tsx
Normal file
131
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api, setAuthToken } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.login(email, password);
|
||||
setAuthToken(data.access_token);
|
||||
localStorage.setItem("trustos_token", data.access_token);
|
||||
localStorage.setItem("trustos_role", data.role);
|
||||
localStorage.setItem("trustos_tenant_id", data.tenant_id);
|
||||
localStorage.setItem("trustos_name", data.full_name);
|
||||
router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Invalid credentials. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-vault-black flex items-center justify-center px-4">
|
||||
{/* Background texture */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_#1a1f2e_0%,_#0a0d14_70%)] pointer-events-none" />
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-vault-sapphire/20 border border-vault-sapphire/40 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-vault-sapphire" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight text-vault-text">
|
||||
Trust<span className="text-vault-sapphire">OS</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-vault-muted text-sm">The AI Operating System for Cyber Resilience</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="vault-card shadow-2xl">
|
||||
<h1 className="text-xl font-semibold text-vault-text mb-6">Sign in to your Vault</h1>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm text-vault-subtle mb-1.5">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="you@company.com"
|
||||
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire focus:ring-1 focus:ring-vault-sapphire transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-vault-subtle mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire focus:ring-1 focus:ring-vault-sapphire transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-vault-crimsonDim border border-vault-crimson/40 text-red-300 text-sm">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="w-full btn-primary justify-center py-2.5 text-base">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
) : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="mt-6 pt-5 border-t border-vault-border">
|
||||
<p className="text-xs text-vault-muted mb-3 font-medium uppercase tracking-wider">Demo Access</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Executive (CEO)", email: "executive@acmecorp.io", pwd: "TrustOS2024!" },
|
||||
{ label: "IT Admin", email: "it@acmecorp.io", pwd: "TrustOS2024!" },
|
||||
{ label: "TrustOS Admin", email: "admin@trustos.com", pwd: "TrustOS-Admin-2024!" },
|
||||
].map(({ label, email: e, pwd }) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => { setEmail(e); setPassword(pwd); }}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-vault-titanium transition-colors text-xs"
|
||||
>
|
||||
<span className="text-vault-sapphireLight font-medium">{label}</span>
|
||||
<span className="text-vault-muted ml-2">{e}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-vault-muted text-xs mt-6">
|
||||
Authorization required for all assessments · Privacy by design
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
}
|
||||
104
frontend/src/app/reports/page.tsx
Normal file
104
frontend/src/app/reports/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { FileText, Download, CheckCircle } from "lucide-react";
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
report_date: string;
|
||||
baseline_score: number | null;
|
||||
executive_summary: string | null;
|
||||
pdf_path: string | null;
|
||||
is_baseline: boolean;
|
||||
generated_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId || role !== "trustos_admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Only admins see this — public endpoint for tenants to view their own would come later
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/audit-reports?tenant_id=${tenantId}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("trustos_token")}` }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(setReports)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId, role]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-vault-sapphire" />
|
||||
Vault Audit Reports
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">Point-in-time baseline reports and audit deliverables</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : role !== "trustos_admin" ? (
|
||||
<div className="vault-card">
|
||||
<p className="text-vault-muted text-sm">Audit reports are managed by your TrustOS administrator.</p>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="vault-card text-center py-12">
|
||||
<FileText className="w-8 h-8 text-vault-muted mx-auto mb-3" />
|
||||
<p className="text-vault-text font-semibold">No audit reports yet</p>
|
||||
<p className="text-vault-muted text-sm mt-1">Generate the first Vault Audit from the admin panel.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map(r => (
|
||||
<div key={r.id} className="vault-card">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{r.is_baseline && (
|
||||
<span className="vault-badge-info text-xs">Baseline</span>
|
||||
)}
|
||||
<p className="text-vault-text font-semibold">{r.title}</p>
|
||||
</div>
|
||||
<p className="text-vault-muted text-xs">
|
||||
{new Date(r.report_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}
|
||||
{r.baseline_score && ` · Score at audit: ${Math.round(r.baseline_score)}`}
|
||||
</p>
|
||||
{r.executive_summary && (
|
||||
<p className="text-vault-subtle text-sm mt-2 leading-relaxed line-clamp-2">{r.executive_summary}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.pdf_path ? (
|
||||
<span className="flex items-center gap-1.5 text-emerald-400 text-xs">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> PDF ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-vault-muted text-xs">PDF generating…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/RiskDial.tsx
Normal file
86
frontend/src/components/RiskDial.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface RiskDialProps {
|
||||
score: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function RiskDial({ score, size = 200 }: RiskDialProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size * 0.38;
|
||||
const startAngle = Math.PI * 0.75; // 135°
|
||||
const endAngle = Math.PI * 2.25; // 405° (full 270° arc)
|
||||
const valueAngle = startAngle + (endAngle - startAngle) * (score / 100);
|
||||
|
||||
// Background track
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, startAngle, endAngle);
|
||||
ctx.strokeStyle = "#2d3447";
|
||||
ctx.lineWidth = 12;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
|
||||
// Score color
|
||||
const getColor = (s: number) => {
|
||||
if (s >= 75) return "#3b82d4"; // sapphire — healthy
|
||||
if (s >= 50) return "#d97706"; // amber — warning
|
||||
return "#dc2626"; // crimson — critical
|
||||
};
|
||||
|
||||
// Score arc
|
||||
if (score > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, startAngle, valueAngle);
|
||||
ctx.strokeStyle = getColor(score);
|
||||
ctx.lineWidth = 12;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
|
||||
// Glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, startAngle, valueAngle);
|
||||
ctx.strokeStyle = getColor(score) + "40";
|
||||
ctx.lineWidth = 20;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Score number
|
||||
ctx.fillStyle = "#e2e8f0";
|
||||
ctx.font = `bold ${size * 0.22}px system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(Math.round(score).toString(), cx, cy - 4);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = "#64748b";
|
||||
ctx.font = `${size * 0.07}px system-ui, sans-serif`;
|
||||
ctx.fillText("/ 100", cx, cy + size * 0.14);
|
||||
}, [score, size]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: size, height: size }}
|
||||
className="drop-shadow-lg"
|
||||
/>
|
||||
<p className="text-vault-muted text-xs mt-1">Cyber Health Score</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ScoreTrend.tsx
Normal file
70
frontend/src/components/ScoreTrend.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine
|
||||
} from "recharts";
|
||||
|
||||
interface ScoreTrendProps {
|
||||
data: { date: string; score: number }[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload?.length) {
|
||||
return (
|
||||
<div className="bg-vault-dark border border-vault-border rounded-lg px-3 py-2 shadow-xl">
|
||||
<p className="text-vault-muted text-xs mb-1">{label}</p>
|
||||
<p className="text-vault-sapphireLight font-bold text-sm">Score: {payload[0].value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function ScoreTrend({ data }: ScoreTrendProps) {
|
||||
// Thin the data to ~30 points for readability
|
||||
const step = Math.ceil(data.length / 30);
|
||||
const thinned = data.filter((_, i) => i % step === 0 || i === data.length - 1);
|
||||
|
||||
// Format dates to short labels
|
||||
const formatted = thinned.map(d => ({
|
||||
...d,
|
||||
shortDate: d.date.slice(5), // MM-DD
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={formatted} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="scoreGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82d4" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82d4" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2d3447" />
|
||||
<XAxis
|
||||
dataKey="shortDate"
|
||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||
axisLine={{ stroke: "#2d3447" }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine y={75} stroke="#3b82d430" strokeDasharray="4 4" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="#3b82d4"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 5, fill: "#3b82d4", stroke: "#0a0d14", strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/Sidebar.tsx
Normal file
107
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
LayoutDashboard, Shield, Search, FileText, Settings, LogOut, ChevronRight
|
||||
} from "lucide-react";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/dashboard", icon: LayoutDashboard, label: "Vault Dashboard" },
|
||||
{ href: "/findings", icon: Shield, label: "Findings" },
|
||||
{ href: "/footprint", icon: Search, label: "Digital Footprint" },
|
||||
{ href: "/reports", icon: FileText, label: "Audit Reports" },
|
||||
];
|
||||
|
||||
const ADMIN_NAV = [
|
||||
{ href: "/admin", icon: Settings, label: "Admin Panel" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { name, role, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-vault-dark border-r border-vault-border flex flex-col z-40">
|
||||
{/* Logo */}
|
||||
<div className="px-6 py-5 border-b border-vault-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-vault-sapphire/20 border border-vault-sapphire/40 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-vault-sapphire" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-vault-text tracking-tight">
|
||||
Trust<span className="text-vault-sapphire">OS</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
{NAV.map(({ href, icon: Icon, label }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-vault-sapphireDim text-vault-sapphireLight border border-vault-sapphire/20"
|
||||
: "text-vault-muted hover:text-vault-text hover:bg-vault-titanium"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{role === "trustos_admin" && (
|
||||
<div className="mt-6">
|
||||
<p className="px-3 text-xs font-semibold text-vault-muted uppercase tracking-wider mb-1">Administration</p>
|
||||
<div className="space-y-0.5">
|
||||
{ADMIN_NAV.map(({ href, icon: Icon, label }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-vault-sapphireDim text-vault-sapphireLight border border-vault-sapphire/20"
|
||||
: "text-vault-muted hover:text-vault-text hover:bg-vault-titanium"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Footer */}
|
||||
<div className="px-3 py-4 border-t border-vault-border">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-vault-sapphire/20 border border-vault-sapphire/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-vault-sapphireLight text-xs font-bold">
|
||||
{name?.charAt(0) ?? "U"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-vault-text text-xs font-medium truncate">{name ?? "User"}</p>
|
||||
<p className="text-vault-muted text-xs capitalize">{role?.replace("_", " ")}</p>
|
||||
</div>
|
||||
<button onClick={logout} className="text-vault-muted hover:text-red-400 transition-colors" title="Sign out">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/TopRiskCard.tsx
Normal file
67
frontend/src/components/TopRiskCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import type { RiskCard } from "@/lib/api";
|
||||
|
||||
const severityConfig: Record<string, { badge: string; icon: React.ElementType; border: string }> = {
|
||||
critical: { badge: "vault-badge-critical", icon: AlertCircle, border: "border-l-4 border-vault-crimson" },
|
||||
high: { badge: "vault-badge-high", icon: AlertTriangle, border: "border-l-4 border-orange-500" },
|
||||
medium: { badge: "vault-badge-medium", icon: AlertTriangle, border: "border-l-4 border-vault-amber" },
|
||||
low: { badge: "vault-badge-low", icon: Info, border: "border-l-4 border-green-500" },
|
||||
};
|
||||
|
||||
const priorityLabel: Record<string, { label: string; color: string }> = {
|
||||
urgent: { label: "Fix within 24h", color: "text-red-400" },
|
||||
soon: { label: "Fix this week", color: "text-amber-400" },
|
||||
planned: { label: "Schedule fix", color: "text-blue-400" },
|
||||
};
|
||||
|
||||
interface TopRiskCardProps {
|
||||
risk: RiskCard;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function TopRiskCard({ risk, index }: TopRiskCardProps) {
|
||||
const config = severityConfig[risk.severity] ?? severityConfig.medium;
|
||||
const Icon = config.icon;
|
||||
const priority = priorityLabel[risk.ai_fix_priority ?? ""] ?? null;
|
||||
|
||||
return (
|
||||
<div className={`vault-card ${config.border} hover:bg-vault-titanium/50 transition-colors`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-vault-dark border border-vault-border flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs font-bold text-vault-muted">{index + 1}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-vault-text font-semibold text-sm leading-tight line-clamp-2">{risk.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={config.badge}>{risk.severity.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{risk.ai_summary && (
|
||||
<p className="text-vault-subtle text-sm leading-relaxed mb-3">{risk.ai_summary}</p>
|
||||
)}
|
||||
|
||||
{risk.ai_business_impact && (
|
||||
<div className="bg-vault-dark/60 rounded-lg px-3 py-2.5 mb-3">
|
||||
<p className="text-xs text-vault-muted mb-1 font-medium uppercase tracking-wider">Business Impact</p>
|
||||
<p className="text-vault-subtle text-sm leading-relaxed">{risk.ai_business_impact}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{priority && (
|
||||
<span className={`text-xs font-semibold ${priority.color}`}>⚡ {priority.label}</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/findings/${risk.id}`}
|
||||
className="ml-auto inline-flex items-center gap-1 text-vault-sapphireLight text-xs font-medium hover:underline"
|
||||
>
|
||||
View details <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/src/hooks/useAuth.ts
Normal file
39
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { setAuthToken } from "@/lib/api";
|
||||
|
||||
export function useAuth() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t = localStorage.getItem("trustos_token");
|
||||
const r = localStorage.getItem("trustos_role");
|
||||
const tid = localStorage.getItem("trustos_tenant_id");
|
||||
const n = localStorage.getItem("trustos_name");
|
||||
if (t) {
|
||||
setToken(t);
|
||||
setRole(r);
|
||||
setTenantId(tid);
|
||||
setName(n);
|
||||
setAuthToken(t);
|
||||
} else if (pathname !== "/login") {
|
||||
router.replace("/login");
|
||||
}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
localStorage.clear();
|
||||
setAuthToken(null);
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
return { token, role, tenantId, name, ready, logout };
|
||||
}
|
||||
127
frontend/src/lib/api.ts
Normal file
127
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (authToken) {
|
||||
headers["Authorization"] = `Bearer ${authToken}`;
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<{ access_token: string; role: string; tenant_id: string; full_name: string }>(
|
||||
"/api/v1/auth/login",
|
||||
{ method: "POST", body: JSON.stringify({ email, password }) }
|
||||
),
|
||||
|
||||
me: () => request<{ id: string; email: string; full_name: string; role: string; tenant_id: string }>(
|
||||
"/api/v1/auth/me"
|
||||
),
|
||||
|
||||
dashboard: (tenantId: string) =>
|
||||
request<DashboardData>(`/api/v1/dashboard/${tenantId}`),
|
||||
|
||||
findings: (tenantId: string, params?: string) =>
|
||||
request<Finding[]>(`/api/v1/findings?tenant_id=${tenantId}${params ? "&" + params : ""}`),
|
||||
|
||||
finding: (id: string) =>
|
||||
request<Finding>(`/api/v1/findings/${id}`),
|
||||
|
||||
updateFindingStatus: (id: string, body: { status: string; resolution_note?: string }) =>
|
||||
request<Finding>(`/api/v1/findings/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
attackPaths: (findingId: string) =>
|
||||
request<AttackPath[]>(`/api/v1/attack-paths/${findingId}`),
|
||||
|
||||
footprint: (tenantId: string) =>
|
||||
request<FootprintData>(`/api/v1/footprint/${tenantId}`),
|
||||
|
||||
aiExplain: (findingId: string, question: string) =>
|
||||
request<{ question: string; answer: string }>(`/api/v1/ai/explain/${findingId}?question=${encodeURIComponent(question)}`),
|
||||
};
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardData {
|
||||
tenant_name: string;
|
||||
current_score: number;
|
||||
previous_score: number | null;
|
||||
score_delta: number | null;
|
||||
score_trend: { date: string; score: number }[];
|
||||
top_risks: RiskCard[];
|
||||
open_critical: number;
|
||||
open_high: number;
|
||||
open_medium: number;
|
||||
total_open: number;
|
||||
baseline_score: number | null;
|
||||
baseline_date: string | null;
|
||||
}
|
||||
|
||||
export interface RiskCard {
|
||||
id: string;
|
||||
title: string;
|
||||
ai_summary: string | null;
|
||||
ai_business_impact: string | null;
|
||||
ai_impact_level: string | null;
|
||||
ai_fix_priority: string | null;
|
||||
severity: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
category: string;
|
||||
technical_description: string | null;
|
||||
cve_id: string | null;
|
||||
cvss_score: number | null;
|
||||
affected_component: string | null;
|
||||
ai_summary: string | null;
|
||||
ai_business_impact: string | null;
|
||||
ai_impact_level: string | null;
|
||||
ai_remediation_steps: string | null;
|
||||
ai_fix_priority: string | null;
|
||||
assignee_email: string | null;
|
||||
due_date: string | null;
|
||||
is_top_risk: boolean;
|
||||
source: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AttackPath {
|
||||
id: string;
|
||||
finding_id: string;
|
||||
title: string;
|
||||
ai_narrative: string | null;
|
||||
nodes_json: string | null;
|
||||
edges_json: string | null;
|
||||
}
|
||||
|
||||
export interface FootprintData {
|
||||
tenant_id: string;
|
||||
executives: { id: string; name: string; title: string; email: string }[];
|
||||
footprint_findings: any[];
|
||||
total_exposures: number;
|
||||
}
|
||||
Reference in New Issue
Block a user