diff --git a/frontend/app/components/DashboardHeader.tsx b/frontend/app/components/DashboardHeader.tsx index c7c2a1e7..480060fd 100644 --- a/frontend/app/components/DashboardHeader.tsx +++ b/frontend/app/components/DashboardHeader.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import React, { useState } from "react"; import type { RejectedFile } from "../lib/upload-validation"; +import type { WorkspaceSummary, AnalysisReport } from "../types"; export type FileProgress = "pending" | "analyzing" | "done" | "error"; @@ -13,6 +14,7 @@ interface DashboardHeaderProps { handleFileUpload: (e: React.ChangeEvent) => void; onContractFiles: (files: File[]) => void; exportToPdf: () => void; + shareReport?: () => Promise; hasData: boolean; isProcessing: boolean; uploadStatus: string | null; @@ -36,6 +38,7 @@ export function DashboardHeader({ handleFileUpload, onContractFiles, exportToPdf, + shareReport, hasData, isProcessing, uploadStatus, @@ -45,6 +48,14 @@ export function DashboardHeader({ rejectedFiles, }: DashboardHeaderProps) { const [isDragging, setIsDragging] = useState(false); + const [shareCopied, setShareCopied] = useState(false); + + const handleShare = async () => { + if (!shareReport) return; + await shareReport(); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -124,6 +135,16 @@ export function DashboardHeader({ > Export PDF + {shareReport && ( + + )} {/* Drag-and-drop batch upload zone */} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 171a58c8..54400449 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -17,6 +17,7 @@ import { SAMPLE_JSON, } from "../lib/report-ingestion"; import { exportToPdf } from "../lib/export-pdf"; +import { copyShareLink, isShareLinkTooLarge } from "../lib/share-link"; import { SeverityFilter } from "../components/SeverityFilter"; import { FindingsList } from "../components/FindingsList"; import { SummaryChart } from "../components/SummaryChart"; @@ -210,6 +211,19 @@ export default function DashboardPage() { setCodeFilterError(validateFindingCodeQuery(normalized)); }, []); + const handleShareReport = async () => { + const workspace = selectedContract?.report + ? { workspace: "sanctifier", contracts: [{ name: selectedContract.name, total_findings: findings.length }], shared_libs: [], grand_total_findings: findings.length } + : null; + const data = workspace ?? currentReport; + if (!data) return; + if (isShareLinkTooLarge(data as Parameters[0])) { + setError("Report is too large to share via URL. Export as PDF instead."); + return; + } + await copyShareLink(data as Parameters[0]); + }; + const hasData = currentReport !== null; const isProcessing = isPending || isUploadingContract; const hasLoadedReport = jsonInput.trim().length > 0; @@ -224,6 +238,7 @@ export default function DashboardPage() { handleFileUpload={handleFileUpload} onContractFiles={handleContractFiles} exportToPdf={() => exportToPdf(findings)} + shareReport={handleShareReport} hasData={hasData} isProcessing={isProcessing} uploadStatus={uploadStatus} diff --git a/frontend/app/lib/share-link.ts b/frontend/app/lib/share-link.ts new file mode 100644 index 00000000..e1ef437c --- /dev/null +++ b/frontend/app/lib/share-link.ts @@ -0,0 +1,46 @@ +import type { WorkspaceSummary, AnalysisReport } from "../types"; + +const PARAM_KEY = "report"; +const MAX_URL_BYTES = 8000; + +function isWorkspaceSummary(v: unknown): v is WorkspaceSummary { + return ( + typeof v === "object" && + v !== null && + "workspace" in v && + "contracts" in v + ); +} + +export function encodeShareLink(data: WorkspaceSummary | AnalysisReport): string { + const json = JSON.stringify(data); + const compressed = btoa(encodeURIComponent(json)); + const url = new URL(window.location.href); + url.searchParams.set(PARAM_KEY, compressed); + return url.toString(); +} + +export function decodeShareLink(search: string): WorkspaceSummary | AnalysisReport | null { + try { + const params = new URLSearchParams(search); + const encoded = params.get(PARAM_KEY); + if (!encoded) return null; + const json = decodeURIComponent(atob(encoded)); + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== "object" || parsed === null) return null; + return parsed as WorkspaceSummary | AnalysisReport; + } catch { + return null; + } +} + +export function isShareLinkTooLarge(data: WorkspaceSummary | AnalysisReport): boolean { + const json = JSON.stringify(data); + const compressed = btoa(encodeURIComponent(json)); + return compressed.length > MAX_URL_BYTES; +} + +export async function copyShareLink(data: WorkspaceSummary | AnalysisReport): Promise { + const link = encodeShareLink(data); + await navigator.clipboard.writeText(link); +}