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>
128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
"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 { 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) return;
|
|
api.reports(tenantId)
|
|
.then(setReports)
|
|
.catch(e => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [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 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>
|
|
) : 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">
|
|
{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">
|
|
{reports.map(r => (
|
|
<div key={r.id} className="vault-card">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{r.is_baseline && (
|
|
<span className="vault-badge-info text-xs">Baseline</span>
|
|
)}
|
|
<p className="text-vault-text font-semibold">{r.title}</p>
|
|
</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 != 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>
|
|
<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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|