Major product upgrade: landing page, scanning UI, admin panel, working reports
Frontend: - New public marketing landing page at / (hero, features, stats, how-it-works, CTA) - New /scans page surfacing the scanning engine: asset health grid, one-click full scan, live scanner findings feed, 24h stats - New /admin panel (was a 404 from the sidebar): audit report generation and instant PDF snapshot download - Reports page: now visible to all roles, working PDF downloads, posture snapshot export for IT/admin - Findings page: full-text search, sorting (severity/newest/title), severity count chips, CSV export - Dashboard: scan activity strip, Run Scan + Export PDF quick actions - Footprint page: summary stat cards - api.ts: scanning, reports, and PDF download endpoints + types; fixed missing resolution_note on Finding type - Removed unsupported eslint key from next.config.ts Backend: - Audit reports: list/get/PDF now open to executives and IT admins with strict tenant isolation (was trustos_admin-only, leaving tenants unable to see their own reports); PDF snapshot open to IT admins; generation stays admin-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,68 +1,91 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, type Report } from "@/lib/api";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { FileText, Download, CheckCircle } from "lucide-react";
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
report_date: string;
|
||||
baseline_score: number | null;
|
||||
executive_summary: string | null;
|
||||
pdf_path: string | null;
|
||||
is_baseline: boolean;
|
||||
generated_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
import { FileText, Download, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { tenantId, role, ready } = useAuth();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [snapshotting, setSnapshotting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !tenantId || role !== "trustos_admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Only admins see this — public endpoint for tenants to view their own would come later
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/audit-reports?tenant_id=${tenantId}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("trustos_token")}` }
|
||||
})
|
||||
.then(r => r.json())
|
||||
if (!ready || !tenantId) return;
|
||||
api.reports(tenantId)
|
||||
.then(setReports)
|
||||
.catch(() => {})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, tenantId, role]);
|
||||
}, [ready, tenantId]);
|
||||
|
||||
async function handleDownload(id: string) {
|
||||
setDownloadingId(id);
|
||||
try {
|
||||
await api.downloadReportPdf(id);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Download failed");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSnapshot() {
|
||||
if (!tenantId) return;
|
||||
setSnapshotting(true);
|
||||
try {
|
||||
await api.downloadPdfSnapshot(tenantId);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Snapshot failed");
|
||||
} finally {
|
||||
setSnapshotting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSnapshot = role === "it_admin" || role === "trustos_admin";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-vault-black">
|
||||
<Sidebar />
|
||||
<main className="ml-64 flex-1 p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-vault-sapphire" />
|
||||
Vault Audit Reports
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">Point-in-time baseline reports and audit deliverables</p>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-vault-sapphire" />
|
||||
Vault Audit Reports
|
||||
</h1>
|
||||
<p className="text-vault-muted text-sm mt-1">Point-in-time baseline reports and audit deliverables</p>
|
||||
</div>
|
||||
{canSnapshot && (
|
||||
<button onClick={handleSnapshot} disabled={snapshotting} className="btn-primary">
|
||||
{snapshotting
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" /> Building PDF…</>
|
||||
: <><Download className="w-4 h-4" /> Current Posture PDF</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="vault-card border-vault-crimson/40 bg-vault-crimsonDim/30 text-red-300 text-sm mb-6 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : role !== "trustos_admin" ? (
|
||||
<div className="vault-card">
|
||||
<p className="text-vault-muted text-sm">Audit reports are managed by your TrustOS administrator.</p>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="vault-card text-center py-12">
|
||||
<FileText className="w-8 h-8 text-vault-muted mx-auto mb-3" />
|
||||
<p className="text-vault-text font-semibold">No audit reports yet</p>
|
||||
<p className="text-vault-muted text-sm mt-1">Generate the first Vault Audit from the admin panel.</p>
|
||||
<p className="text-vault-muted text-sm mt-1">
|
||||
{role === "trustos_admin"
|
||||
? "Generate the first Vault Audit from the Admin Panel."
|
||||
: "Your TrustOS administrator will publish audit reports here."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -78,21 +101,21 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
<p className="text-vault-muted text-xs">
|
||||
{new Date(r.report_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}
|
||||
{r.baseline_score && ` · Score at audit: ${Math.round(r.baseline_score)}`}
|
||||
{r.baseline_score != null && ` · Score at audit: ${Math.round(r.baseline_score)}`}
|
||||
</p>
|
||||
{r.executive_summary && (
|
||||
<p className="text-vault-subtle text-sm mt-2 leading-relaxed line-clamp-2">{r.executive_summary}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.pdf_path ? (
|
||||
<span className="flex items-center gap-1.5 text-emerald-400 text-xs">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> PDF ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-vault-muted text-xs">PDF generating…</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(r.id)}
|
||||
disabled={downloadingId === r.id}
|
||||
className="btn-ghost border border-vault-border rounded-lg flex-shrink-0"
|
||||
>
|
||||
{downloadingId === r.id
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" /> Preparing…</>
|
||||
: <><Download className="w-4 h-4" /> Download PDF</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user