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