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>
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
"use client";
|
|
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, Search, Download } from "lucide-react";
|
|
|
|
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",
|
|
resolved: "vault-badge-low",
|
|
verified: "vault-badge-info",
|
|
accepted_risk: "vault-badge-info",
|
|
};
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
external_exposure: "External Exposure",
|
|
cloud_posture: "Cloud Posture",
|
|
credential_exposure: "Credential Exposure",
|
|
digital_footprint: "Digital Footprint",
|
|
web_application: "Web Application",
|
|
network: "Network",
|
|
identity: "Identity",
|
|
third_party: "Third Party",
|
|
compliance: "Compliance",
|
|
other: "Other",
|
|
};
|
|
|
|
type SortKey = "severity" | "created" | "title";
|
|
|
|
export default function FindingsPage() {
|
|
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;
|
|
const params = [
|
|
filterSeverity !== "all" ? `severity=${filterSeverity}` : "",
|
|
filterStatus !== "all" ? `status=${filterStatus}` : "",
|
|
].filter(Boolean).join("&");
|
|
api.findings(tenantId, params)
|
|
.then(setFindings)
|
|
.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 />
|
|
<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">
|
|
<Shield className="w-6 h-6 text-vault-sapphire" />
|
|
Findings
|
|
</h1>
|
|
<p className="text-vault-muted text-sm mt-0.5">All security findings across your environment</p>
|
|
</div>
|
|
<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 */}
|
|
<div className="flex gap-3 mb-6 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-vault-muted" />
|
|
<span className="text-vault-muted text-sm">Filter:</span>
|
|
</div>
|
|
{["all", "critical", "high", "medium", "low"].map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => { setFilterSeverity(s); setLoading(true); }}
|
|
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
|
|
filterSeverity === s
|
|
? "bg-vault-sapphire text-white border-vault-sapphire"
|
|
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
|
|
}`}
|
|
>
|
|
{s === "all" ? "All Severities" : s.toUpperCase()}
|
|
</button>
|
|
))}
|
|
<div className="border-l border-vault-border mx-1" />
|
|
{["all", "open", "in_progress", "resolved", "verified"].map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => { setFilterStatus(s); setLoading(true); }}
|
|
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
|
|
filterStatus === s
|
|
? "bg-vault-sapphire text-white border-vault-sapphire"
|
|
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
|
|
}`}
|
|
>
|
|
{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 */}
|
|
<div className="vault-card overflow-hidden p-0">
|
|
{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>
|
|
) : 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>
|
|
</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-vault-border">
|
|
{["Severity", "Title", "Category", "Status", "Priority", ""].map(h => (
|
|
<th key={h} className="px-5 py-3 text-left text-xs font-semibold text-vault-muted uppercase tracking-wider">
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<p className="text-vault-text text-sm font-medium leading-snug max-w-md">{f.title}</p>
|
|
{f.ai_summary && (
|
|
<p className="text-vault-muted text-xs mt-0.5 line-clamp-1">{f.ai_summary}</p>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-4 text-vault-muted text-xs whitespace-nowrap">
|
|
{CATEGORY_LABELS[f.category] ?? f.category}
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<span className={STATUS_COLORS[f.status] ?? "vault-badge-info"}>
|
|
{f.status.replace("_", " ")}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
{f.ai_fix_priority && (
|
|
<span className={
|
|
f.ai_fix_priority === "urgent" ? "text-red-400 text-xs font-semibold" :
|
|
f.ai_fix_priority === "soon" ? "text-amber-400 text-xs font-semibold" :
|
|
"text-vault-muted text-xs"
|
|
}>
|
|
{f.ai_fix_priority}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<Link
|
|
href={`/findings/${f.id}`}
|
|
className="text-vault-sapphireLight text-xs hover:underline whitespace-nowrap"
|
|
>
|
|
View →
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|