From 2bdc085bc3e7dededfe588a2e4ab25c8ba4da40c Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 7 Jul 2026 14:42:24 +0000 Subject: [PATCH] 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 --- backend/app/api/routes/reports.py | 19 +- frontend/next.config.ts | 3 - frontend/src/app/admin/page.tsx | 165 +++++++++++++++ frontend/src/app/dashboard/page.tsx | 75 ++++++- frontend/src/app/findings/[id]/page.tsx | 2 +- frontend/src/app/findings/page.tsx | 102 ++++++++- frontend/src/app/footprint/page.tsx | 17 ++ frontend/src/app/page.tsx | 261 +++++++++++++++++++++++- frontend/src/app/reports/page.tsx | 117 ++++++----- frontend/src/app/scans/page.tsx | 239 ++++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 5 +- frontend/src/lib/api.ts | 96 +++++++++ 12 files changed, 1024 insertions(+), 77 deletions(-) create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/scans/page.tsx diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py index b58b898..2300e17 100644 --- a/backend/app/api/routes/reports.py +++ b/backend/app/api/routes/reports.py @@ -9,7 +9,12 @@ import json from app.db.session import get_db from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus, Tenant from app.schemas.schemas import AuditReportOut, AuditReportCreate -from app.core.security import require_admin +from app.core.security import require_admin, require_executive_or_above, require_it_or_above + + +def _check_tenant_access(payload: dict, tenant_id: str): + if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id: + raise HTTPException(status_code=403, detail="Access denied") router = APIRouter(prefix="/audit-reports", tags=["audit-reports"]) @@ -17,9 +22,10 @@ router = APIRouter(prefix="/audit-reports", tags=["audit-reports"]) @router.get("", response_model=List[AuditReportOut]) async def list_reports( tenant_id: str = Query(...), - payload: dict = Depends(require_admin), + payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): + _check_tenant_access(payload, tenant_id) result = await db.execute( select(AuditReport) .where(AuditReport.tenant_id == tenant_id) @@ -89,26 +95,28 @@ async def generate_audit_report( @router.get("/{report_id}", response_model=AuditReportOut) async def get_report( report_id: str, - payload: dict = Depends(require_admin), + payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(AuditReport).where(AuditReport.id == report_id)) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=404, detail="Report not found") + _check_tenant_access(payload, report.tenant_id) return report @router.get("/{report_id}/pdf") async def download_report_pdf( report_id: str, - payload: dict = Depends(require_admin), + payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(AuditReport).where(AuditReport.id == report_id)) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=404, detail="Report not found") + _check_tenant_access(payload, report.tenant_id) tenant_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id)) tenant = tenant_result.scalar_one_or_none() @@ -142,10 +150,11 @@ async def download_report_pdf( @router.post("/{tenant_id}/pdf-snapshot") async def generate_pdf_snapshot( tenant_id: str, - payload: dict = Depends(require_admin), + payload: dict = Depends(require_it_or_above), db: AsyncSession = Depends(get_db), ): """Generate a one-off PDF report for a tenant (not stored as a record).""" + _check_tenant_access(payload, tenant_id) tenant_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) tenant = tenant_result.scalar_one_or_none() if not tenant: diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 5204212..9873e47 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,9 +9,6 @@ const nextConfig: NextConfig = { }, ]; }, - eslint: { - ignoreDuringBuilds: true, - }, }; export default nextConfig; diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..8182c00 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,165 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { api, type Report } from "@/lib/api"; +import Sidebar from "@/components/Sidebar"; +import { + Settings, FileText, Download, Play, CheckCircle2, AlertCircle, RefreshCw +} from "lucide-react"; + +export default function AdminPage() { + const { tenantId, role, ready } = useAuth(); + const [reports, setReports] = useState([]); + const [title, setTitle] = useState("Vault Audit Report"); + const [summary, setSummary] = useState(""); + const [generating, setGenerating] = useState(false); + const [downloading, setDownloading] = useState(false); + const [notice, setNotice] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + useEffect(() => { + if (!ready || !tenantId || role !== "trustos_admin") return; + api.reports(tenantId).then(setReports).catch(() => {}); + }, [ready, tenantId, role]); + + if (ready && role !== "trustos_admin") { + return ( +
+ +
+
+

Admin access required

+

This panel is only available to TrustOS administrators.

+
+
+
+ ); + } + + async function handleGenerate(e: React.FormEvent) { + e.preventDefault(); + if (!tenantId) return; + setGenerating(true); + setNotice(null); + try { + const report = await api.generateReport(tenantId, { + title, + executive_summary: summary || undefined, + }); + setReports(prev => [report, ...prev]); + setNotice({ kind: "ok", text: `Report "${report.title}" generated — snapshot score ${report.baseline_score ? Math.round(report.baseline_score) : "n/a"}.` }); + setSummary(""); + } catch (err: any) { + setNotice({ kind: "err", text: err.message || "Failed to generate report" }); + } finally { + setGenerating(false); + } + } + + async function handleSnapshot() { + if (!tenantId) return; + setDownloading(true); + setNotice(null); + try { + await api.downloadPdfSnapshot(tenantId); + setNotice({ kind: "ok", text: "PDF snapshot downloaded." }); + } catch (err: any) { + setNotice({ kind: "err", text: err.message || "Failed to generate PDF" }); + } finally { + setDownloading(false); + } + } + + return ( +
+ +
+
+

+ + Admin Panel +

+

Audit report generation and tenant operations

+
+ + {notice && ( +
+ {notice.kind === "ok" ? : } + {notice.text} +
+ )} + +
+ {/* Generate audit report */} +
+

+ Generate Vault Audit Report +

+

Snapshots the current score and top findings as a permanent audit record.

+
+
+ + setTitle(e.target.value)} + required + className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text text-sm focus:outline-none focus:border-vault-sapphire" + /> +
+
+ +