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,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>
|
||||
|
||||
Reference in New Issue
Block a user