Major product upgrade: landing page, scanning UI, admin panel, working reports
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

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:
drjones
2026-07-07 14:42:24 +00:00
parent d92a7c057d
commit 2bdc085bc3
12 changed files with 1024 additions and 77 deletions

View File

@@ -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>