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:
drjones
2026-07-05 09:46:08 +00:00
commit 463dff883b
64 changed files with 11679 additions and 0 deletions

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}