Major product upgrade: landing page, scanning UI, admin panel, working reports
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / docker-build (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled
Test / security-scan (push) Has been cancelled

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:
drjones
2026-07-07 14:42:24 +00:00
parent d92a7c057d
commit 2bdc085bc3
12 changed files with 1024 additions and 77 deletions

View File

@@ -9,7 +9,12 @@ import json
from app.db.session import get_db
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus, Tenant
from app.schemas.schemas import AuditReportOut, AuditReportCreate
from app.core.security import require_admin
from app.core.security import require_admin, require_executive_or_above, require_it_or_above
def _check_tenant_access(payload: dict, tenant_id: str):
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
router = APIRouter(prefix="/audit-reports", tags=["audit-reports"])
@@ -17,9 +22,10 @@ router = APIRouter(prefix="/audit-reports", tags=["audit-reports"])
@router.get("", response_model=List[AuditReportOut])
async def list_reports(
tenant_id: str = Query(...),
payload: dict = Depends(require_admin),
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
_check_tenant_access(payload, tenant_id)
result = await db.execute(
select(AuditReport)
.where(AuditReport.tenant_id == tenant_id)
@@ -89,26 +95,28 @@ async def generate_audit_report(
@router.get("/{report_id}", response_model=AuditReportOut)
async def get_report(
report_id: str,
payload: dict = Depends(require_admin),
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
_check_tenant_access(payload, report.tenant_id)
return report
@router.get("/{report_id}/pdf")
async def download_report_pdf(
report_id: str,
payload: dict = Depends(require_admin),
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
_check_tenant_access(payload, report.tenant_id)
tenant_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
tenant = tenant_result.scalar_one_or_none()
@@ -142,10 +150,11 @@ async def download_report_pdf(
@router.post("/{tenant_id}/pdf-snapshot")
async def generate_pdf_snapshot(
tenant_id: str,
payload: dict = Depends(require_admin),
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
"""Generate a one-off PDF report for a tenant (not stored as a record)."""
_check_tenant_access(payload, tenant_id)
tenant_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = tenant_result.scalar_one_or_none()
if not tenant:

View File

@@ -9,9 +9,6 @@ const nextConfig: NextConfig = {
},
];
},
eslint: {
ignoreDuringBuilds: true,
},
};
export default nextConfig;

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

View File

@@ -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>
<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">

View File

@@ -182,7 +182,7 @@ export default function FindingDetailPage() {
<AttackPathVisualizer
nodes={nodes}
edges={edges}
narrative={path.ai_narrative}
narrative={path.ai_narrative ?? undefined}
/>
</div>
);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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&apos;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&apos;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&apos;s model, and your assessments never touch
anything you haven&apos;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&apos;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>
);
}

View File

@@ -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">
<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>
))}

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

View File

@@ -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

View File

@@ -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 }[];