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.db.session import get_db
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus, Tenant from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus, Tenant
from app.schemas.schemas import AuditReportOut, AuditReportCreate 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"]) 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]) @router.get("", response_model=List[AuditReportOut])
async def list_reports( async def list_reports(
tenant_id: str = Query(...), tenant_id: str = Query(...),
payload: dict = Depends(require_admin), payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_check_tenant_access(payload, tenant_id)
result = await db.execute( result = await db.execute(
select(AuditReport) select(AuditReport)
.where(AuditReport.tenant_id == tenant_id) .where(AuditReport.tenant_id == tenant_id)
@@ -89,26 +95,28 @@ async def generate_audit_report(
@router.get("/{report_id}", response_model=AuditReportOut) @router.get("/{report_id}", response_model=AuditReportOut)
async def get_report( async def get_report(
report_id: str, report_id: str,
payload: dict = Depends(require_admin), payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id)) result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = result.scalar_one_or_none() report = result.scalar_one_or_none()
if not report: if not report:
raise HTTPException(status_code=404, detail="Report not found") raise HTTPException(status_code=404, detail="Report not found")
_check_tenant_access(payload, report.tenant_id)
return report return report
@router.get("/{report_id}/pdf") @router.get("/{report_id}/pdf")
async def download_report_pdf( async def download_report_pdf(
report_id: str, report_id: str,
payload: dict = Depends(require_admin), payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id)) result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = result.scalar_one_or_none() report = result.scalar_one_or_none()
if not report: if not report:
raise HTTPException(status_code=404, detail="Report not found") 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_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
tenant = tenant_result.scalar_one_or_none() tenant = tenant_result.scalar_one_or_none()
@@ -142,10 +150,11 @@ async def download_report_pdf(
@router.post("/{tenant_id}/pdf-snapshot") @router.post("/{tenant_id}/pdf-snapshot")
async def generate_pdf_snapshot( async def generate_pdf_snapshot(
tenant_id: str, tenant_id: str,
payload: dict = Depends(require_admin), payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Generate a one-off PDF report for a tenant (not stored as a record).""" """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_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = tenant_result.scalar_one_or_none() tenant = tenant_result.scalar_one_or_none()
if not tenant: if not tenant:

View File

@@ -9,9 +9,6 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
eslint: {
ignoreDuringBuilds: true,
},
}; };
export default nextConfig; 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 { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; 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 Sidebar from "@/components/Sidebar";
import RiskDial from "@/components/RiskDial"; import RiskDial from "@/components/RiskDial";
import TopRiskCard from "@/components/TopRiskCard"; import TopRiskCard from "@/components/TopRiskCard";
import ScoreTrend from "@/components/ScoreTrend"; 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() { export default function DashboardPage() {
const { tenantId, role, ready } = useAuth(); const { tenantId, role, ready } = useAuth();
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<DashboardData | null>(null); const [data, setData] = useState<DashboardData | null>(null);
const [scan, setScan] = useState<ScanStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [exporting, setExporting] = useState(false);
const isIt = role === "it_admin" || role === "trustos_admin";
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
@@ -23,7 +31,17 @@ export default function DashboardPage() {
.then(setData) .then(setData)
.catch(e => setError(e.message)) .catch(e => setError(e.message))
.finally(() => setLoading(false)); .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 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"; 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> </p>
<h1 className="text-2xl font-bold text-vault-text">Cyber Resilience Overview</h1> <h1 className="text-2xl font-bold text-vault-text">Cyber Resilience Overview</h1>
</div> </div>
{data?.baseline_date && ( <div className="flex items-center gap-3">
<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"> {data?.baseline_date && (
<Calendar className="w-3.5 h-3.5" /> <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">
Audit baseline: {new Date(data.baseline_date).toLocaleDateString()} <Calendar className="w-3.5 h-3.5" />
</div> 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>
</div> </div>
@@ -120,6 +152,31 @@ export default function DashboardPage() {
</div> </div>
</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 */} {/* Score trend */}
<div className="vault-card mb-8"> <div className="vault-card mb-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

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

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { api, type Finding } from "@/lib/api"; import { api, type Finding } from "@/lib/api";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import Link from "next/link"; 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> = { const STATUS_COLORS: Record<string, string> = {
open: "vault-badge-critical", open: "vault-badge-critical",
in_progress: "vault-badge-medium", in_progress: "vault-badge-medium",
@@ -27,12 +27,16 @@ const CATEGORY_LABELS: Record<string, string> = {
other: "Other", other: "Other",
}; };
type SortKey = "severity" | "created" | "title";
export default function FindingsPage() { export default function FindingsPage() {
const { tenantId, role, ready } = useAuth(); const { tenantId, ready } = useAuth();
const [findings, setFindings] = useState<Finding[]>([]); const [findings, setFindings] = useState<Finding[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filterSeverity, setFilterSeverity] = useState("all"); const [filterSeverity, setFilterSeverity] = useState("all");
const [filterStatus, setFilterStatus] = useState("open"); const [filterStatus, setFilterStatus] = useState("open");
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("severity");
useEffect(() => { useEffect(() => {
if (!ready || !tenantId) return; if (!ready || !tenantId) return;
@@ -45,6 +49,53 @@ export default function FindingsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [ready, tenantId, filterSeverity, filterStatus]); }, [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 ( return (
<div className="flex min-h-screen bg-vault-black"> <div className="flex min-h-screen bg-vault-black">
<Sidebar /> <Sidebar />
@@ -57,7 +108,43 @@ export default function FindingsPage() {
</h1> </h1>
<p className="text-vault-muted text-sm mt-0.5">All security findings across your environment</p> <p className="text-vault-muted text-sm mt-0.5">All security findings across your environment</p>
</div> </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> </div>
{/* Filters */} {/* Filters */}
@@ -93,6 +180,7 @@ export default function FindingsPage() {
{s === "all" ? "All Statuses" : s.replace("_", " ")} {s === "all" ? "All Statuses" : s.replace("_", " ")}
</button> </button>
))} ))}
<span className="ml-auto text-vault-muted text-sm self-center">{visible.length} results</span>
</div> </div>
{/* Findings table */} {/* Findings table */}
@@ -101,7 +189,7 @@ export default function FindingsPage() {
<div className="flex items-center justify-center h-40"> <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 className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div> </div>
) : findings.length === 0 ? ( ) : visible.length === 0 ? (
<div className="text-center py-16"> <div className="text-center py-16">
<CheckCircle2 className="w-10 h-10 text-emerald-400 mx-auto mb-3" /> <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> <p className="text-vault-text font-semibold">No findings match these filters</p>
@@ -118,7 +206,7 @@ export default function FindingsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <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"> <tr key={f.id} className="border-b border-vault-border/50 hover:bg-vault-titanium/30 transition-colors">
<td className="px-5 py-4"> <td className="px-5 py-4">
<span className={`vault-badge-${f.severity}`}>{f.severity.toUpperCase()}</span> <span className={`vault-badge-${f.severity}`}>{f.severity.toUpperCase()}</span>

View File

@@ -35,6 +35,23 @@ export default function FootprintPage() {
</div> </div>
) : data && ( ) : 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 */} {/* Executive Exposure */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2"> <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() { const FEATURES = [
redirect("/login"); {
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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api"; import { api, type Report } from "@/lib/api";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { FileText, Download, CheckCircle } from "lucide-react"; import { FileText, Download, RefreshCw } from "lucide-react";
interface Report {
id: string;
tenant_id: string;
title: string;
report_date: string;
baseline_score: number | null;
executive_summary: string | null;
pdf_path: string | null;
is_baseline: boolean;
generated_by: string | null;
created_at: string;
}
export default function ReportsPage() { export default function ReportsPage() {
const { tenantId, role, ready } = useAuth(); const { tenantId, role, ready } = useAuth();
const [reports, setReports] = useState<Report[]>([]); const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [snapshotting, setSnapshotting] = useState(false);
useEffect(() => { useEffect(() => {
if (!ready || !tenantId || role !== "trustos_admin") { if (!ready || !tenantId) return;
setLoading(false); api.reports(tenantId)
return;
}
// Only admins see this — public endpoint for tenants to view their own would come later
fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/audit-reports?tenant_id=${tenantId}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("trustos_token")}` }
})
.then(r => r.json())
.then(setReports) .then(setReports)
.catch(() => {}) .catch(e => setError(e.message))
.finally(() => setLoading(false)); .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 ( return (
<div className="flex min-h-screen bg-vault-black"> <div className="flex min-h-screen bg-vault-black">
<Sidebar /> <Sidebar />
<main className="ml-64 flex-1 p-8"> <main className="ml-64 flex-1 p-8">
<div className="mb-6"> <div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2"> <div>
<FileText className="w-6 h-6 text-vault-sapphire" /> <h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
Vault Audit Reports <FileText className="w-6 h-6 text-vault-sapphire" />
</h1> Vault Audit Reports
<p className="text-vault-muted text-sm mt-1">Point-in-time baseline reports and audit deliverables</p> </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> </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 ? ( {loading ? (
<div className="flex items-center justify-center h-40"> <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 className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div> </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 ? ( ) : reports.length === 0 ? (
<div className="vault-card text-center py-12"> <div className="vault-card text-center py-12">
<FileText className="w-8 h-8 text-vault-muted mx-auto mb-3" /> <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-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>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -78,21 +101,21 @@ export default function ReportsPage() {
</div> </div>
<p className="text-vault-muted text-xs"> <p className="text-vault-muted text-xs">
{new Date(r.report_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} {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> </p>
{r.executive_summary && ( {r.executive_summary && (
<p className="text-vault-subtle text-sm mt-2 leading-relaxed line-clamp-2">{r.executive_summary}</p> <p className="text-vault-subtle text-sm mt-2 leading-relaxed line-clamp-2">{r.executive_summary}</p>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <button
{r.pdf_path ? ( onClick={() => handleDownload(r.id)}
<span className="flex items-center gap-1.5 text-emerald-400 text-xs"> disabled={downloadingId === r.id}
<CheckCircle className="w-3.5 h-3.5" /> PDF ready className="btn-ghost border border-vault-border rounded-lg flex-shrink-0"
</span> >
) : ( {downloadingId === r.id
<span className="text-vault-muted text-xs">PDF generating</span> ? <><RefreshCw className="w-4 h-4 animate-spin" /> Preparing</>
)} : <><Download className="w-4 h-4" /> Download PDF</>}
</div> </button>
</div> </div>
</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 { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { import {
LayoutDashboard, Shield, Search, FileText, Settings, LogOut, ChevronRight LayoutDashboard, Shield, Search, FileText, Settings, LogOut, Radar
} from "lucide-react"; } from "lucide-react";
const NAV = [ const NAV = [
{ href: "/dashboard", icon: LayoutDashboard, label: "Vault Dashboard" }, { href: "/dashboard", icon: LayoutDashboard, label: "Vault Dashboard" },
{ href: "/findings", icon: Shield, label: "Findings" }, { 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: "/footprint", icon: Search, label: "Digital Footprint" },
{ href: "/reports", icon: FileText, label: "Audit Reports" }, { href: "/reports", icon: FileText, label: "Audit Reports" },
]; ];
@@ -40,7 +41,7 @@ export default function Sidebar() {
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 py-4 overflow-y-auto"> <nav className="flex-1 px-3 py-4 overflow-y-auto">
<div className="space-y-0.5"> <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 + "/"); const active = pathname === href || pathname.startsWith(href + "/");
return ( return (
<Link <Link

View File

@@ -22,6 +22,25 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return res.json(); 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 = { export const api = {
login: (email: string, password: string) => login: (email: string, password: string) =>
request<{ access_token: string; role: string; tenant_id: string; full_name: 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) => aiExplain: (findingId: string, question: string) =>
request<{ question: string; answer: string }>(`/api/v1/ai/explain/${findingId}?question=${encodeURIComponent(question)}`), 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) => aiExplainFinding: (findingId: string) =>
request<{ request<{
finding_id: string; finding_id: string;
@@ -115,6 +166,7 @@ export interface Finding {
ai_impact_level: string | null; ai_impact_level: string | null;
ai_remediation_steps: string | null; ai_remediation_steps: string | null;
ai_fix_priority: string | null; ai_fix_priority: string | null;
resolution_note: string | null;
assignee_email: string | null; assignee_email: string | null;
due_date: string | null; due_date: string | null;
is_top_risk: boolean; is_top_risk: boolean;
@@ -132,6 +184,50 @@ export interface AttackPath {
edges_json: string | null; 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 { export interface FootprintData {
tenant_id: string; tenant_id: string;
executives: { id: string; name: string; title: string; email: string }[]; executives: { id: string; name: string; title: string; email: string }[];