Major product upgrade: landing page, scanning UI, admin panel, working reports
Frontend: - New public marketing landing page at / (hero, features, stats, how-it-works, CTA) - New /scans page surfacing the scanning engine: asset health grid, one-click full scan, live scanner findings feed, 24h stats - New /admin panel (was a 404 from the sidebar): audit report generation and instant PDF snapshot download - Reports page: now visible to all roles, working PDF downloads, posture snapshot export for IT/admin - Findings page: full-text search, sorting (severity/newest/title), severity count chips, CSV export - Dashboard: scan activity strip, Run Scan + Export PDF quick actions - Footprint page: summary stat cards - api.ts: scanning, reports, and PDF download endpoints + types; fixed missing resolution_note on Finding type - Removed unsupported eslint key from next.config.ts Backend: - Audit reports: list/get/PDF now open to executives and IT admins with strict tenant isolation (was trustos_admin-only, leaving tenants unable to see their own reports); PDF snapshot open to IT admins; generation stays admin-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
165
frontend/src/app/admin/page.tsx
Normal file
165
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type Report } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import {
|
||||
Settings, FileText, Download, Play, CheckCircle2, AlertCircle, RefreshCw
|
||||
} from "lucide-react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [title, setTitle] = useState("Vault Audit Report");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [notice, setNotice] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId || role !== "trustos_admin") return;
|
||||
api.reports(tenantId).then(setReports).catch(() => {});
|
||||
}, [ready, tenantId, role]);
|
||||
|
||||
if (ready && role !== "trustos_admin") {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
<div className="vault-card max-w-lg">
|
||||
<p className="text-vault-text font-semibold mb-1">Admin access required</p>
|
||||
<p className="text-vault-muted text-sm">This panel is only available to TrustOS administrators.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!tenantId) return;
|
||||
setGenerating(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const report = await api.generateReport(tenantId, {
|
||||
title,
|
||||
executive_summary: summary || undefined,
|
||||
});
|
||||
setReports(prev => [report, ...prev]);
|
||||
setNotice({ kind: "ok", text: `Report "${report.title}" generated — snapshot score ${report.baseline_score ? Math.round(report.baseline_score) : "n/a"}.` });
|
||||
setSummary("");
|
||||
} catch (err: any) {
|
||||
setNotice({ kind: "err", text: err.message || "Failed to generate report" });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSnapshot() {
|
||||
if (!tenantId) return;
|
||||
setDownloading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.downloadPdfSnapshot(tenantId);
|
||||
setNotice({ kind: "ok", text: "PDF snapshot downloaded." });
|
||||
} catch (err: any) {
|
||||
setNotice({ kind: "err", text: err.message || "Failed to generate PDF" });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<Settings className="w-6 h-6 text-vault-sapphire" />
|
||||
Admin Panel
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">Audit report generation and tenant operations</p>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className={`vault-card mb-6 py-3 text-sm flex items-center gap-2 ${
|
||||
notice.kind === "ok"
|
||||
? "border-vault-emerald/40 bg-vault-emeraldDim/30 text-emerald-400"
|
||||
: "border-vault-crimson/40 bg-vault-crimsonDim/30 text-red-300"
|
||||
}`}>
|
||||
{notice.kind === "ok" ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Generate audit report */}
|
||||
<div className="vault-card">
|
||||
<h2 className="text-vault-text font-semibold mb-1 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-vault-sapphire" /> Generate Vault Audit Report
|
||||
</h2>
|
||||
<p className="text-vault-muted text-xs mb-5">Snapshots the current score and top findings as a permanent audit record.</p>
|
||||
<form onSubmit={handleGenerate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-vault-subtle mb-1.5">Report title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text text-sm focus:outline-none focus:border-vault-sapphire"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-vault-subtle mb-1.5">Executive summary (optional)</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="High-level narrative for the report cover…"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={generating} className="btn-primary">
|
||||
{generating ? <><RefreshCw className="w-4 h-4 animate-spin" /> Generating…</> : <><Play className="w-4 h-4" /> Generate Report</>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="vault-card">
|
||||
<h2 className="text-vault-text font-semibold mb-1 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-vault-sapphire" /> Instant PDF Snapshot
|
||||
</h2>
|
||||
<p className="text-vault-muted text-xs mb-5">
|
||||
Generates a full findings + score-trend PDF for this tenant without creating an audit record. Perfect for ad-hoc board requests.
|
||||
</p>
|
||||
<button onClick={handleSnapshot} disabled={downloading} className="btn-primary">
|
||||
{downloading ? <><RefreshCw className="w-4 h-4 animate-spin" /> Building PDF…</> : <><Download className="w-4 h-4" /> Download PDF Snapshot</>}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 pt-5 border-t border-vault-border">
|
||||
<p className="text-xs text-vault-muted uppercase tracking-wider font-medium mb-3">Recent Reports</p>
|
||||
{reports.length === 0 ? (
|
||||
<p className="text-vault-muted text-sm">No reports generated yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reports.slice(0, 5).map(r => (
|
||||
<div key={r.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-vault-subtle truncate mr-3">{r.title}</span>
|
||||
<button
|
||||
onClick={() => api.downloadReportPdf(r.id).catch(() => {})}
|
||||
className="text-vault-sapphireLight text-xs hover:underline flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<Download className="w-3 h-3" /> PDF
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type DashboardData } from "@/lib/api";
|
||||
import { api, type DashboardData, type ScanStatus } 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";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
TrendingUp, TrendingDown, Minus, AlertCircle, AlertTriangle, Activity, Calendar,
|
||||
Radar, Download, Shield, RefreshCw
|
||||
} from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [scan, setScan] = useState<ScanStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const isIt = role === "it_admin" || role === "trustos_admin";
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
@@ -23,7 +31,17 @@ export default function DashboardPage() {
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId]);
|
||||
if (role === "it_admin" || role === "trustos_admin") {
|
||||
api.scanStatus(tenantId).then(setScan).catch(() => {});
|
||||
}
|
||||
}, [ready, tenantId, role]);
|
||||
|
||||
async function handleExport() {
|
||||
if (!tenantId) return;
|
||||
setExporting(true);
|
||||
try { await api.downloadPdfSnapshot(tenantId); } catch { /* surfaced via button state only */ }
|
||||
setExporting(false);
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -41,12 +59,26 @@ export default function DashboardPage() {
|
||||
</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 className="flex items-center gap-3">
|
||||
{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>
|
||||
)}
|
||||
{isIt && (
|
||||
<>
|
||||
<Link href="/scans" className="btn-ghost border border-vault-border rounded-lg text-xs">
|
||||
<Radar className="w-3.5 h-3.5" /> Run Scan
|
||||
</Link>
|
||||
<button onClick={handleExport} disabled={exporting} className="btn-primary text-xs">
|
||||
{exporting
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Exporting…</>
|
||||
: <><Download className="w-3.5 h-3.5" /> Export PDF</>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +152,31 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan activity strip (IT roles) */}
|
||||
{isIt && scan && (
|
||||
<div className="vault-card mb-8 py-4 flex items-center justify-between gap-6 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-vault-sapphire/15 border border-vault-sapphire/30 flex items-center justify-center">
|
||||
<Radar className="w-4 h-4 text-vault-sapphire" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-vault-text text-sm font-semibold">Continuous Scanning</p>
|
||||
<p className="text-vault-muted text-xs">
|
||||
{scan.last_scan
|
||||
? `Last scanner activity ${new Date(scan.last_scan).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
|
||||
: "No automated scans in the last 24 hours"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<span className="text-vault-subtle">{scan.findings_found} new in 24h</span>
|
||||
{scan.critical_count > 0 && <span className="text-red-400 font-semibold">{scan.critical_count} critical</span>}
|
||||
{scan.high_count > 0 && <span className="text-orange-400 font-semibold">{scan.high_count} high</span>}
|
||||
<Link href="/scans" className="text-vault-sapphireLight text-xs hover:underline">View scanning →</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score trend */}
|
||||
<div className="vault-card mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function FindingDetailPage() {
|
||||
<AttackPathVisualizer
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
narrative={path.ai_narrative}
|
||||
narrative={path.ai_narrative ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, 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";
|
||||
import { Shield, Filter, ArrowUpDown, CheckCircle2, Search, Download } from "lucide-react";
|
||||
|
||||
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
||||
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: "vault-badge-critical",
|
||||
in_progress: "vault-badge-medium",
|
||||
@@ -27,12 +27,16 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
type SortKey = "severity" | "created" | "title";
|
||||
|
||||
export default function FindingsPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const { tenantId, ready } = useAuth();
|
||||
const [findings, setFindings] = useState<Finding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterSeverity, setFilterSeverity] = useState("all");
|
||||
const [filterStatus, setFilterStatus] = useState("open");
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("severity");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId) return;
|
||||
@@ -45,6 +49,53 @@ export default function FindingsPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId, filterSeverity, filterStatus]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
let list = findings;
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase();
|
||||
list = list.filter(f =>
|
||||
f.title.toLowerCase().includes(q) ||
|
||||
(f.ai_summary ?? "").toLowerCase().includes(q) ||
|
||||
(f.affected_component ?? "").toLowerCase().includes(q) ||
|
||||
(f.cve_id ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortKey === "severity") {
|
||||
return (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9);
|
||||
}
|
||||
if (sortKey === "created") {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [findings, query, sortKey]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const f of findings) if (f.severity in c) c[f.severity]++;
|
||||
return c;
|
||||
}, [findings]);
|
||||
|
||||
function exportCsv() {
|
||||
const header = ["Title", "Severity", "Status", "Category", "CVE", "CVSS", "Component", "Created"];
|
||||
const rows = visible.map(f => [
|
||||
f.title, f.severity, f.status, f.category,
|
||||
f.cve_id ?? "", f.cvss_score ?? "", f.affected_component ?? "",
|
||||
new Date(f.created_at).toISOString().slice(0, 10),
|
||||
]);
|
||||
const csv = [header, ...rows]
|
||||
.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "trustos_findings.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
@@ -57,7 +108,43 @@ export default function FindingsPage() {
|
||||
</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 className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{(["critical", "high", "medium", "low"] as const).map(s => (
|
||||
counts[s] > 0 && (
|
||||
<span key={s} className={`vault-badge-${s}`}>{counts[s]} {s}</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
<button onClick={exportCsv} className="btn-ghost border border-vault-border rounded-lg" title="Export CSV">
|
||||
<Download className="w-4 h-4" /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + sort */}
|
||||
<div className="flex gap-3 mb-4 items-center flex-wrap">
|
||||
<div className="relative flex-1 min-w-64 max-w-md">
|
||||
<Search className="w-4 h-4 text-vault-muted absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search title, CVE, component…"
|
||||
className="w-full pl-9 pr-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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="w-4 h-4 text-vault-muted" />
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={e => setSortKey(e.target.value as SortKey)}
|
||||
className="px-3 py-2 rounded-lg bg-vault-dark border border-vault-border text-vault-text text-sm focus:outline-none focus:border-vault-sapphire"
|
||||
>
|
||||
<option value="severity">Sort: Severity</option>
|
||||
<option value="created">Sort: Newest</option>
|
||||
<option value="title">Sort: Title</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -93,6 +180,7 @@ export default function FindingsPage() {
|
||||
{s === "all" ? "All Statuses" : s.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-vault-muted text-sm self-center">{visible.length} results</span>
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
@@ -101,7 +189,7 @@ export default function FindingsPage() {
|
||||
<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 ? (
|
||||
) : visible.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>
|
||||
@@ -118,7 +206,7 @@ export default function FindingsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{findings.map(f => (
|
||||
{visible.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>
|
||||
|
||||
@@ -35,6 +35,23 @@ export default function FootprintPage() {
|
||||
</div>
|
||||
) : data && (
|
||||
<>
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Executives Monitored", value: data.executives.length, icon: User, color: "text-vault-sapphireLight" },
|
||||
{ label: "Active Exposures", value: data.total_exposures, icon: AlertTriangle, color: data.total_exposures > 0 ? "text-orange-400" : "text-emerald-400" },
|
||||
{ label: "Monitoring Status", value: "Active", icon: Shield, color: "text-emerald-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="vault-card">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-vault-muted text-xs font-medium">{label}</p>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Executive Exposure */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,260 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Shield, Zap, Brain, FileText, Radar, GitBranch, Lock, TrendingUp,
|
||||
ArrowRight, CheckCircle2, Sparkles, Eye, Target, BarChart3
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: Brain,
|
||||
title: "AI Risk Translation",
|
||||
desc: "CVE-2024-XXXX means nothing to your board. TrustOS translates every technical finding into plain-English business impact — automatically.",
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "Attack Path Visualization",
|
||||
desc: "See exactly how an attacker chains your weaknesses together. Interactive attack graphs turn abstract risk into an undeniable picture.",
|
||||
},
|
||||
{
|
||||
icon: Radar,
|
||||
title: "Continuous Asset Scanning",
|
||||
desc: "Every domain, server, and cloud asset monitored around the clock. New exposures surface as findings within minutes, not months.",
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: "Executive Digital Footprint",
|
||||
desc: "Your leadership team is your biggest attack surface. Track credential leaks and public exposure for every executive.",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "One-Click Audit Reports",
|
||||
desc: "Board meeting tomorrow? Generate a polished, PDF-ready cyber resilience report in seconds — scores, trends, and top risks included.",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "AI Security Coach",
|
||||
desc: "Ask any finding anything. Your built-in AI coach explains the threat, the fix, and the priority — in language everyone understands.",
|
||||
},
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ value: "89.2", label: "Avg. cyber health score achieved" },
|
||||
{ value: "< 5 min", label: "From signup to first insight" },
|
||||
{ value: "24/7", label: "Continuous exposure monitoring" },
|
||||
{ value: "100%", label: "Findings translated for the board" },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ n: "01", icon: Target, title: "Connect your assets", desc: "Enroll domains, cloud accounts, and executives. Authorized scope only — privacy by design." },
|
||||
{ n: "02", icon: Radar, title: "We scan continuously", desc: "TrustOS maps your exposure, scores every asset, and flags what attackers would exploit first." },
|
||||
{ n: "03", icon: TrendingUp, title: "Watch risk fall", desc: "Fix what matters, track your Cyber Health Score climb, and prove progress with audit-grade reports." },
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(!!localStorage.getItem("trustos_token"));
|
||||
}, []);
|
||||
|
||||
const ctaHref = loggedIn ? "/dashboard" : "/login";
|
||||
const ctaLabel = loggedIn ? "Open Your Vault" : "Get Started Free";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-vault-black text-vault-text overflow-x-hidden">
|
||||
{/* Nav */}
|
||||
<header className="fixed top-0 inset-x-0 z-50 bg-vault-black/80 backdrop-blur border-b border-vault-border">
|
||||
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<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">
|
||||
<Shield className="w-4 h-4 text-vault-sapphire" />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
Trust<span className="text-vault-sapphire">OS</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="hidden md:flex items-center gap-8 text-sm text-vault-subtle">
|
||||
<a href="#features" className="hover:text-vault-text transition-colors">Features</a>
|
||||
<a href="#how" className="hover:text-vault-text transition-colors">How It Works</a>
|
||||
<a href="#security" className="hover:text-vault-text transition-colors">Security</a>
|
||||
</nav>
|
||||
<Link href={ctaHref} className="btn-primary">
|
||||
{loggedIn ? "Dashboard" : "Sign In"} <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative pt-36 pb-24 px-6">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_#1e3a5f_0%,_#0a0d14_60%)] pointer-events-none" />
|
||||
<div className="relative max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-vault-sapphire/10 border border-vault-sapphire/30 text-vault-sapphireLight text-xs font-semibold mb-8">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
AI-powered cyber resilience — live in minutes
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-bold leading-tight tracking-tight mb-6">
|
||||
Know your cyber risk.
|
||||
<br />
|
||||
<span className="text-vault-sapphireLight">Prove you're fixing it.</span>
|
||||
</h1>
|
||||
<p className="text-vault-subtle text-lg md:text-xl max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
TrustOS is the AI operating system that turns raw vulnerabilities into a single
|
||||
Cyber Health Score your board understands — and a prioritized plan your IT team can execute.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link href={ctaHref} className="btn-primary text-base px-8 py-3">
|
||||
{ctaLabel} <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link href="/login" className="btn-ghost text-base px-8 py-3 border border-vault-border rounded-lg">
|
||||
Try the Live Demo
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-vault-muted text-xs mt-6">
|
||||
Demo access included · No credit card · Authorized assessments only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score preview card */}
|
||||
<div className="relative max-w-3xl mx-auto mt-16">
|
||||
<div className="vault-card border-vault-sapphire/30 shadow-2xl">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-8">
|
||||
<div className="relative w-36 h-36 flex-shrink-0">
|
||||
<svg viewBox="0 0 120 120" className="w-full h-full -rotate-90">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#2d3447" strokeWidth="10" />
|
||||
<circle
|
||||
cx="60" cy="60" r="52" fill="none" stroke="#3b82d4" strokeWidth="10"
|
||||
strokeLinecap="round" strokeDasharray={`${89.2 * 3.267} 326.7`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center rotate-0">
|
||||
<span className="text-3xl font-bold text-vault-text">89.2</span>
|
||||
<span className="text-vault-muted text-xs">Health Score</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-vault-sapphireLight text-xs font-semibold uppercase tracking-wider mb-2">Live from the demo vault</p>
|
||||
<h3 className="text-xl font-semibold mb-2">One score. Total clarity.</h3>
|
||||
<p className="text-vault-subtle text-sm leading-relaxed">
|
||||
Every finding, every asset, every executive exposure — distilled into a single number
|
||||
that trends over 90 days. When the score goes up, you have proof. When it dips, you know why first.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4 text-emerald-400 text-sm font-semibold">
|
||||
<TrendingUp className="w-4 h-4" /> +6.8 pts in the last 30 days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats bar */}
|
||||
<section className="border-y border-vault-border bg-vault-dark/50">
|
||||
<div className="max-w-6xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{STATS.map(s => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className="text-3xl font-bold text-vault-sapphireLight mb-1">{s.value}</p>
|
||||
<p className="text-vault-muted text-xs leading-snug">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section id="features" className="max-w-6xl mx-auto px-6 py-24">
|
||||
<div className="text-center mb-14">
|
||||
<p className="text-vault-sapphireLight text-xs font-semibold uppercase tracking-wider mb-3">The Platform</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Everything a security team needs.<br />Nothing a board can't read.</h2>
|
||||
<p className="text-vault-subtle max-w-2xl mx-auto">
|
||||
Six capabilities, one vault. Built for growing companies that need enterprise-grade resilience without an enterprise-grade security team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{FEATURES.map(({ icon: Icon, title, desc }) => (
|
||||
<div key={title} className="vault-card hover:border-vault-sapphire/40 transition-colors group">
|
||||
<div className="w-10 h-10 rounded-lg bg-vault-sapphire/15 border border-vault-sapphire/30 flex items-center justify-center mb-4 group-hover:bg-vault-sapphire/25 transition-colors">
|
||||
<Icon className="w-5 h-5 text-vault-sapphire" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">{title}</h3>
|
||||
<p className="text-vault-muted text-sm leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id="how" className="border-y border-vault-border bg-vault-dark/30">
|
||||
<div className="max-w-6xl mx-auto px-6 py-24">
|
||||
<div className="text-center mb-14">
|
||||
<p className="text-vault-sapphireLight text-xs font-semibold uppercase tracking-wider mb-3">How It Works</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold">From blind spot to board-ready in three steps</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{STEPS.map(({ n, icon: Icon, title, desc }) => (
|
||||
<div key={n} className="relative vault-card">
|
||||
<span className="absolute -top-4 left-6 px-3 py-1 rounded-full bg-vault-sapphire text-white text-xs font-bold">{n}</span>
|
||||
<Icon className="w-6 h-6 text-vault-sapphire mb-4 mt-2" />
|
||||
<h3 className="font-semibold mb-2">{title}</h3>
|
||||
<p className="text-vault-muted text-sm leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security / trust */}
|
||||
<section id="security" className="max-w-6xl mx-auto px-6 py-24">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<p className="text-vault-sapphireLight text-xs font-semibold uppercase tracking-wider mb-3">Built Trustworthy</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">Security software that practices what it preaches</h2>
|
||||
<p className="text-vault-subtle leading-relaxed mb-8">
|
||||
Multi-tenant isolation, role-based access, encrypted transport, and authorized-scope-only
|
||||
assessments. Your data never trains anyone else's model, and your assessments never touch
|
||||
anything you haven't explicitly enrolled.
|
||||
</p>
|
||||
<Link href={ctaHref} className="btn-primary text-base px-8 py-3">
|
||||
{ctaLabel} <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ icon: Lock, label: "JWT auth + bcrypt hashing" },
|
||||
{ icon: Shield, label: "Strict multi-tenant isolation" },
|
||||
{ icon: BarChart3, label: "Role-based dashboards (3 roles)" },
|
||||
{ icon: CheckCircle2, label: "Authorized scope only — always" },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<div key={label} className="vault-card flex items-center gap-3 py-4">
|
||||
<Icon className="w-5 h-5 text-emerald-400 flex-shrink-0" />
|
||||
<span className="text-sm text-vault-subtle">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="border-t border-vault-border bg-[radial-gradient(ellipse_at_bottom,_#1e3a5f_0%,_#0a0d14_70%)]">
|
||||
<div className="max-w-3xl mx-auto px-6 py-24 text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-6">Your attackers already know your weaknesses.</h2>
|
||||
<p className="text-vault-subtle text-lg mb-10">It's time you did too. Open your vault and see your real exposure in under five minutes.</p>
|
||||
<Link href={ctaHref} className="btn-primary text-base px-10 py-3.5">
|
||||
{ctaLabel} <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-vault-border">
|
||||
<div className="max-w-6xl mx-auto px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-vault-muted">
|
||||
<Shield className="w-4 h-4 text-vault-sapphire" />
|
||||
TrustOS — The AI Operating System for Cyber Resilience
|
||||
</div>
|
||||
<p className="text-vault-muted text-xs">Authorization required for all assessments · Privacy by design</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,91 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, type Report } 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;
|
||||
}
|
||||
import { FileText, Download, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [snapshotting, setSnapshotting] = useState(false);
|
||||
|
||||
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())
|
||||
if (!ready || !tenantId) return;
|
||||
api.reports(tenantId)
|
||||
.then(setReports)
|
||||
.catch(() => {})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId, role]);
|
||||
}, [ready, tenantId]);
|
||||
|
||||
async function handleDownload(id: string) {
|
||||
setDownloadingId(id);
|
||||
try {
|
||||
await api.downloadReportPdf(id);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Download failed");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSnapshot() {
|
||||
if (!tenantId) return;
|
||||
setSnapshotting(true);
|
||||
try {
|
||||
await api.downloadPdfSnapshot(tenantId);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Snapshot failed");
|
||||
} finally {
|
||||
setSnapshotting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSnapshot = role === "it_admin" || role === "trustos_admin";
|
||||
|
||||
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 className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
{canSnapshot && (
|
||||
<button onClick={handleSnapshot} disabled={snapshotting} className="btn-primary">
|
||||
{snapshotting
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" /> Building PDF…</>
|
||||
: <><Download className="w-4 h-4" /> Current Posture PDF</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="vault-card border-vault-crimson/40 bg-vault-crimsonDim/30 text-red-300 text-sm mb-6 py-3">
|
||||
{error}
|
||||
</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>
|
||||
<p className="text-vault-muted text-sm mt-1">
|
||||
{role === "trustos_admin"
|
||||
? "Generate the first Vault Audit from the Admin Panel."
|
||||
: "Your TrustOS administrator will publish audit reports here."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -78,21 +101,21 @@ export default function ReportsPage() {
|
||||
</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)}`}
|
||||
{r.baseline_score != null && ` · 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>
|
||||
<button
|
||||
onClick={() => handleDownload(r.id)}
|
||||
disabled={downloadingId === r.id}
|
||||
className="btn-ghost border border-vault-border rounded-lg flex-shrink-0"
|
||||
>
|
||||
{downloadingId === r.id
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" /> Preparing…</>
|
||||
: <><Download className="w-4 h-4" /> Download PDF</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
239
frontend/src/app/scans/page.tsx
Normal file
239
frontend/src/app/scans/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api, type ScanStatus, type AssetHealth, type ScanFinding } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Radar, Play, Server, Globe, Cloud, AlertCircle, RefreshCw, CheckCircle2, Clock
|
||||
} from "lucide-react";
|
||||
|
||||
const ASSET_ICONS: Record<string, typeof Server> = {
|
||||
domain: Globe,
|
||||
server: Server,
|
||||
cloud: Cloud,
|
||||
};
|
||||
|
||||
function healthColor(score: number) {
|
||||
if (score >= 80) return "text-emerald-400";
|
||||
if (score >= 60) return "text-amber-400";
|
||||
if (score >= 30) return "text-orange-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
function healthBar(score: number) {
|
||||
if (score >= 80) return "bg-vault-emerald";
|
||||
if (score >= 60) return "bg-vault-amber";
|
||||
return "bg-vault-crimson";
|
||||
}
|
||||
|
||||
export default function ScansPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [assets, setAssets] = useState<AssetHealth[]>([]);
|
||||
const [recent, setRecent] = useState<ScanFinding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!tenantId) return;
|
||||
Promise.all([
|
||||
api.scanStatus(tenantId).then(setStatus).catch(() => {}),
|
||||
api.assetHealth(tenantId).then(setAssets).catch(() => {}),
|
||||
api.recentScanFindings(tenantId).then(setRecent).catch(() => {}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId) return;
|
||||
load();
|
||||
}, [ready, tenantId, load]);
|
||||
|
||||
async function handleStartScan() {
|
||||
if (!tenantId) return;
|
||||
setScanning(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.startScan(tenantId);
|
||||
setMessage(`Scan started on ${res.assets_scanned} asset${res.assets_scanned === 1 ? "" : "s"}. Results will appear below as they complete.`);
|
||||
// Refresh after a delay to pick up new findings
|
||||
setTimeout(load, 8000);
|
||||
} catch (e: any) {
|
||||
setMessage(e.message || "Failed to start scan");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canScan = role === "it_admin" || role === "trustos_admin";
|
||||
|
||||
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">
|
||||
<Radar className="w-6 h-6 text-vault-sapphire" />
|
||||
Continuous Scanning
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">Asset health, automated scans, and freshly discovered exposures</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={load} className="btn-ghost border border-vault-border rounded-lg" title="Refresh">
|
||||
<RefreshCw className="w-4 h-4" /> Refresh
|
||||
</button>
|
||||
{canScan && (
|
||||
<button onClick={handleStartScan} disabled={scanning} className="btn-primary">
|
||||
{scanning ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Starting…</>
|
||||
) : (
|
||||
<><Play className="w-4 h-4" /> Run Full Scan</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="vault-card border-vault-sapphire/40 bg-vault-sapphireDim/30 text-vault-sapphireLight text-sm mb-6 py-3">
|
||||
{message}
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{/* Scan status summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{
|
||||
label: "Last 24h Findings",
|
||||
value: status?.findings_found ?? 0,
|
||||
icon: AlertCircle,
|
||||
color: "text-vault-text",
|
||||
},
|
||||
{
|
||||
label: "Critical",
|
||||
value: status?.critical_count ?? 0,
|
||||
icon: AlertCircle,
|
||||
color: (status?.critical_count ?? 0) > 0 ? "text-red-400" : "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "High",
|
||||
value: status?.high_count ?? 0,
|
||||
icon: AlertCircle,
|
||||
color: (status?.high_count ?? 0) > 0 ? "text-orange-400" : "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "Last Scan",
|
||||
value: status?.last_scan ? new Date(status.last_scan).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "—",
|
||||
icon: Clock,
|
||||
color: "text-vault-subtle",
|
||||
},
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="vault-card">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-vault-muted text-xs font-medium">{label}</p>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Asset health */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-vault-sapphire" /> Asset Health
|
||||
</h2>
|
||||
{assets.length === 0 ? (
|
||||
<div className="vault-card text-center py-10">
|
||||
<p className="text-vault-muted text-sm">No assets enrolled yet. Assets are added during Vault onboarding.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{assets.map(a => {
|
||||
const Icon = ASSET_ICONS[a.asset_type] ?? Server;
|
||||
return (
|
||||
<div key={a.asset_id} className="vault-card">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-vault-sapphire/15 border border-vault-sapphire/30 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-4 h-4 text-vault-sapphire" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-vault-text text-sm font-medium truncate">{a.asset_name}</p>
|
||||
<p className="text-vault-muted text-xs truncate">{a.asset_value}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-lg font-bold flex-shrink-0 ${healthColor(a.health_score)}`}>
|
||||
{a.health_score}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-vault-titanium overflow-hidden mb-3">
|
||||
<div
|
||||
className={`h-full rounded-full ${healthBar(a.health_score)}`}
|
||||
style={{ width: `${a.health_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-vault-muted">
|
||||
<span>{a.findings_count} finding{a.findings_count === 1 ? "" : "s"}</span>
|
||||
{a.critical_count > 0 ? (
|
||||
<span className="text-red-400 font-semibold">{a.critical_count} critical</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> no criticals
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent scanner findings */}
|
||||
<div>
|
||||
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-vault-sapphire" /> Recent Scanner Findings
|
||||
</h2>
|
||||
{recent.length === 0 ? (
|
||||
<div className="vault-card text-center py-10">
|
||||
<p className="text-vault-muted text-sm">No automated scanner findings yet.</p>
|
||||
{canScan && <p className="text-vault-muted text-xs mt-1">Run a full scan to populate this feed.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vault-card p-0 overflow-hidden">
|
||||
{recent.map(f => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/findings/${f.id}`}
|
||||
className="flex items-center gap-4 px-5 py-3.5 border-b border-vault-border/50 last:border-0 hover:bg-vault-titanium/30 transition-colors"
|
||||
>
|
||||
<span className={`vault-badge-${f.severity} flex-shrink-0`}>{f.severity.toUpperCase()}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-vault-text text-sm font-medium truncate">{f.title}</p>
|
||||
{f.affected_component && (
|
||||
<p className="text-vault-muted text-xs truncate">{f.affected_component}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-vault-muted text-xs flex-shrink-0">
|
||||
{new Date(f.found_at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
LayoutDashboard, Shield, Search, FileText, Settings, LogOut, ChevronRight
|
||||
LayoutDashboard, Shield, Search, FileText, Settings, LogOut, Radar
|
||||
} from "lucide-react";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/dashboard", icon: LayoutDashboard, label: "Vault Dashboard" },
|
||||
{ href: "/findings", icon: Shield, label: "Findings" },
|
||||
{ href: "/scans", icon: Radar, label: "Scanning", roles: ["it_admin", "trustos_admin"] },
|
||||
{ href: "/footprint", icon: Search, label: "Digital Footprint" },
|
||||
{ href: "/reports", icon: FileText, label: "Audit Reports" },
|
||||
];
|
||||
@@ -40,7 +41,7 @@ export default function Sidebar() {
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
{NAV.map(({ href, icon: Icon, label }) => {
|
||||
{NAV.filter(item => !item.roles || (role && item.roles.includes(role))).map(({ href, icon: Icon, label }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -22,6 +22,25 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadBlob(path: string, filename: string, method: string = "GET"): Promise<void> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
const res = await fetch(`${BASE}${path}`, { method, headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<{ access_token: string; role: string; tenant_id: string; full_name: string }>(
|
||||
@@ -57,6 +76,38 @@ export const api = {
|
||||
aiExplain: (findingId: string, question: string) =>
|
||||
request<{ question: string; answer: string }>(`/api/v1/ai/explain/${findingId}?question=${encodeURIComponent(question)}`),
|
||||
|
||||
// ── Audit Reports ──
|
||||
reports: (tenantId: string) =>
|
||||
request<Report[]>(`/api/v1/audit-reports?tenant_id=${tenantId}`),
|
||||
|
||||
generateReport: (tenantId: string, body: { title: string; executive_summary?: string; scope_description?: string }) =>
|
||||
request<Report>(`/api/v1/audit-reports/generate?tenant_id=${tenantId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
downloadReportPdf: (reportId: string) =>
|
||||
downloadBlob(`/api/v1/audit-reports/${reportId}/pdf`, `trustos_report_${reportId}.pdf`),
|
||||
|
||||
downloadPdfSnapshot: (tenantId: string) =>
|
||||
downloadBlob(`/api/v1/audit-reports/${tenantId}/pdf-snapshot`, `trustos_snapshot_${tenantId}.pdf`, "POST"),
|
||||
|
||||
// ── Scanning ──
|
||||
startScan: (tenantId: string) =>
|
||||
request<{ status: string; assets_scanned: number }>(
|
||||
`/api/v1/scanning/start-scan?tenant_id=${tenantId}`,
|
||||
{ method: "POST", body: JSON.stringify({ asset_ids: [], scan_all_assets: true }) }
|
||||
),
|
||||
|
||||
scanStatus: (tenantId: string) =>
|
||||
request<ScanStatus>(`/api/v1/scanning/status?tenant_id=${tenantId}`),
|
||||
|
||||
assetHealth: (tenantId: string) =>
|
||||
request<AssetHealth[]>(`/api/v1/scanning/asset-health?tenant_id=${tenantId}`),
|
||||
|
||||
recentScanFindings: (tenantId: string, limit = 20) =>
|
||||
request<ScanFinding[]>(`/api/v1/scanning/recent-findings?tenant_id=${tenantId}&limit=${limit}`),
|
||||
|
||||
aiExplainFinding: (findingId: string) =>
|
||||
request<{
|
||||
finding_id: string;
|
||||
@@ -115,6 +166,7 @@ export interface Finding {
|
||||
ai_impact_level: string | null;
|
||||
ai_remediation_steps: string | null;
|
||||
ai_fix_priority: string | null;
|
||||
resolution_note: string | null;
|
||||
assignee_email: string | null;
|
||||
due_date: string | null;
|
||||
is_top_risk: boolean;
|
||||
@@ -132,6 +184,50 @@ export interface AttackPath {
|
||||
edges_json: string | null;
|
||||
}
|
||||
|
||||
export 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 interface ScanStatus {
|
||||
status: string;
|
||||
last_scan: string | null;
|
||||
findings_found: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
completion_percentage: number;
|
||||
}
|
||||
|
||||
export interface AssetHealth {
|
||||
asset_id: string;
|
||||
asset_name: string;
|
||||
asset_value: string;
|
||||
asset_type: string;
|
||||
findings_count: number;
|
||||
critical_count: number;
|
||||
health_score: number;
|
||||
risk_level: string;
|
||||
}
|
||||
|
||||
export interface ScanFinding {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
affected_component: string | null;
|
||||
found_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface FootprintData {
|
||||
tenant_id: string;
|
||||
executives: { id: string; name: string; title: string; email: string }[];
|
||||
|
||||
Reference in New Issue
Block a user