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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user