Files
trustos/frontend/src/app/findings/page.tsx
drjones 2bdc085bc3
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
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>
2026-07-07 14:42:24 +00:00

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>
);
}