diff --git a/strix/viewer_src/src/App.tsx b/strix/viewer_src/src/App.tsx index 812a0b6a..e3e16708 100644 --- a/strix/viewer_src/src/App.tsx +++ b/strix/viewer_src/src/App.tsx @@ -1,13 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ShieldCheck, ArrowLeft, - Lock, - GitPullRequest, - CalendarClock, - Rocket, AlertCircle, Waypoints, + Mail, + ChevronDown, + Wrench, + FileCheck2, + CalendarClock, + Radar, + GitPullRequest, + Rocket, + Radio, + ArrowUpRight, + History, } from "lucide-react"; import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues"; import { SEVERITY_COLORS } from "@/types/issues"; @@ -19,28 +26,83 @@ import AgentGraph from "@/components/live/AgentGraph"; import { buildGraphAgents } from "@/components/live/AgentTranscript"; import AgentDetailModal from "@/components/live/AgentDetailModal"; import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser"; -import { fetchAll, fetchRunSummary, fetchTranscript, fetchVulnerabilities, type LoadedRun } from "@/data/serverSource"; +import { + fetchAll, + fetchAuthStatus, + fetchRunSummary, + fetchRuns, + fetchTranscript, + fetchVulnerabilities, + forgetAuth, + type AuthStatus, + type LoadedRun, + type RunsPayload, +} from "@/data/serverSource"; import { SIGNUP_URL, trackCta } from "@/lib/cta"; +import Sidebar from "@/components/Sidebar"; +import PastRunsView from "@/components/PastRunsView"; +import EmailReportDialog from "@/components/EmailReportDialog"; +import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta"; + +export type View = "overview" | "issues" | "agents" | "history"; const TRUST_BANNER = - "Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix."; + "Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix. Emailing a report is an explicit opt-in that sends an encrypted copy only you can open."; const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"]; const POLL_MS = 500; +// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell +// tiles), plus the recommendations pairing. +const RECOMMENDATION_CTAS: ProItem[] = [ + { title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench }, + { title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 }, +]; +const COVERAGE_CTAS: ProItem[] = [ + { title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock }, + { title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar }, + { title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest }, +]; + export default function App() { + const [activeRun, setActiveRun] = useState(null); const [run, setRun] = useState(null); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); - const [view, setView] = useState<"overview" | "issues" | "agents">("overview"); + const [view, setView] = useState("overview"); + const [auth, setAuth] = useState(null); + const [runs, setRuns] = useState(null); + const [emailOpen, setEmailOpen] = useState(false); - // Live polling. On mount fetch everything; while the run is unfinished, poll - // /api/run each second and refresh transcript + vulnerabilities; once - // finished, do one final full fetch and stop. + const refreshAuth = useCallback(async () => { + try { + setAuth(await fetchAuthStatus()); + } catch { + /* auth status is best-effort; the launched run stays viewable */ + } + }, []); + + const refreshRuns = useCallback(async () => { + try { + setRuns(await fetchRuns()); + } catch { + /* history list is best-effort */ + } + }, []); + + useEffect(() => { + void refreshAuth(); + void refreshRuns(); + }, [refreshAuth, refreshRuns]); + + // Live polling, scoped to the active run. Re-runs when the active run changes + // so switching to a past run (?run=) reloads its data; a finished run + // does a single full fetch and stops. const finishedRef = useRef(false); useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; + finishedRef.current = false; const schedule = () => { timer = setTimeout(tick, POLL_MS); @@ -49,17 +111,17 @@ export default function App() { const tick = async () => { if (cancelled) return; try { - const { summary, raw, finished } = await fetchRunSummary(); + const { summary, raw, finished } = await fetchRunSummary(activeRun); if (cancelled) return; if (finished && !finishedRef.current) { finishedRef.current = true; - const full = await fetchAll(); + const full = await fetchAll(activeRun); if (!cancelled) setRun(full); return; // stop polling } const [transcript, vulnerabilities] = await Promise.all([ - fetchTranscript().catch(() => ({ agents: [], events: [] })), - fetchVulnerabilities(summary.runId).catch(() => [] as Vulnerability[]), + fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })), + fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]), ]); if (cancelled) return; setRun((prev) => ({ @@ -80,7 +142,7 @@ export default function App() { (async () => { try { - const full = await fetchAll(); + const full = await fetchAll(activeRun); if (cancelled) return; setRun(full); if (full.finished) { @@ -99,7 +161,7 @@ export default function App() { cancelled = true; if (timer) clearTimeout(timer); }; - }, []); + }, [activeRun]); const counts = useMemo( () => (run ? severityCounts(run.vulnerabilities) : null), @@ -107,98 +169,238 @@ export default function App() { ); const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null; const agentCount = run?.transcript.agents.length ?? 0; + const verified = auth?.verified === true; + + const selectRun = useCallback((name: string) => { + setActiveRun(name); + setSelectedId(null); + setRun(null); + setError(null); + setView("overview"); + }, []); + + const openEmail = useCallback(() => { + trackCta("email_report"); + setEmailOpen(true); + }, []); + + const openHistory = useCallback(() => { + trackCta("history_unlock"); + void refreshRuns(); + setView("history"); + }, [refreshRuns]); + + const onForget = useCallback(async () => { + await forgetAuth(); + await refreshAuth(); + await refreshRuns(); + }, [refreshAuth, refreshRuns]); return ( -
- {/* Top bar */} - +
+ { + if (v === "history") openHistory(); + else setView(v); + }} + issuesCount={run?.vulnerabilities.length ?? 0} + agentCount={agentCount} + runCount={runs?.count ?? 0} + verified={verified} + email={auth?.email ?? null} + onOpenEmail={openEmail} + onOpenHistory={openHistory} + onForget={() => void onForget()} + /> -
- {/* Trust banner */} -
-
- - {error && !run && ( -
-
- )} - - {!run && !error && ( -
-
-

Loading run data…

-
- )} - - {run && counts && ( - <> - - - - -
- setView("overview")}> - Overview - - setView("issues")}> - Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""} - - {agentCount > 0 && ( - setView("agents")}> - Agents ({agentCount}) - +
+ {/* Top bar */} + - {view === "overview" ? ( - - ) : view === "agents" && agentCount > 0 ? ( - - ) : selected ? ( -
- - +
+ {/* Trust banner */} +
+
+ + {error && !run && view !== "history" && ( +
+
+ )} + + {view === "history" ? ( +
+
+
- ) : ( - setSelectedId(id)} + - )} - - )} +
+ ) : !run && !error ? ( +
+
+

Loading run data…

+
+ ) : run && counts ? ( + <> + + + {/* Tab strip: shown on small screens where the sidebar is hidden. */} +
+ setView("overview")}> + Overview + + setView("issues")}> + Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""} + + {agentCount > 0 && ( + setView("agents")}> + Agents ({agentCount}) + + )} +
+ + {view === "overview" ? ( + + ) : view === "agents" && agentCount > 0 ? ( + + ) : selected ? ( +
+ + +
+ ) : ( + setSelectedId(id)} + /> + )} + + ) : null} +
+ + setEmailOpen(false)} + activeRun={activeRun} + auth={auth} + onVerified={() => { + void refreshAuth(); + void refreshRuns(); + }} + /> +
+ ); +} + +function RunSwitcher({ + runs, + activeRun, + launchedName, + onSelect, +}: { + runs: RunsPayload; + activeRun: string | null; + launchedName: string; + onSelect: (name: string) => void; +}) { + const [open, setOpen] = useState(false); + const current = activeRun ?? launchedName; + return ( +
+ + {open && ( +
+ {runs.runs.map((r) => { + const active = r.name === activeRun; + return ( + + ); + })} +
+ )}
); } @@ -206,14 +408,14 @@ export default function App() { function LiveIndicator({ finished }: { finished: boolean }) { if (finished) { return ( - + Complete ); } return ( - + @@ -274,8 +476,24 @@ function FindingsList({ ); if (sorted.length === 0) { return ( -
- {finished ? "No findings in this run." : "No findings yet. The scan is still running…"} +
+
+ {finished ? "No findings in this run." : "No findings yet. The scan is still running…"} +
+ {finished && ( +
+

Stay ahead of new exposures

+

+ Attack surface monitoring catches new exposures for your org over time. +

+ +
+ )}
); } @@ -327,16 +545,46 @@ function dedupeHeadings(md: string): string { return out.join("\n"); } +/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */ +function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) { + return ( + + ); +} + function OverviewTab({ summary, counts, total, reportMarkdown, + onOpenEmail, }: { summary: ParsedRunSummary; counts: Record; total: number; reportMarkdown: string | null; + onOpenEmail: () => void; }) { const sections = ( [ @@ -356,6 +604,10 @@ function OverviewTab({
)} + + {/* Primary CTA: the one primary on Overview. */} + + {sections.length > 0 ? (
{sections.map((s) => ( @@ -371,6 +623,23 @@ function OverviewTab({

No summary available for this run yet.

) )} + + {/* Near Recommendations: act on the fixes. */} +
+ {RECOMMENDATION_CTAS.map((item) => ( + + ))} +
+ + {/* Continuous coverage for your org (restyled upsell tiles). */} +
+

Continuous coverage for your org

+
+ {COVERAGE_CTAS.map((item) => ( + + ))} +
+
); } @@ -397,54 +666,6 @@ function TabButton({ ); } -const UPSELLS: { feature: string; title: string; desc: string; icon: React.ElementType }[] = [ - { - feature: "cloud pentests", - title: "Re-run in Strix Cloud", - desc: "Run this scan on managed infra with more depth.", - icon: Rocket, - }, - { - feature: "scheduled scans", - title: "Schedule recurring scans", - desc: "Continuously retest on a cadence you choose.", - icon: CalendarClock, - }, - { - feature: "PR reviews", - title: "PR security reviews", - desc: "Catch vulnerabilities in every pull request.", - icon: GitPullRequest, - }, -]; - -function UpsellRow() { - return ( - - ); -} - function AgentsTab({ run }: { run: LoadedRun }) { const { agents, events } = run.transcript; const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]); @@ -478,6 +699,26 @@ function AgentsTab({ run }: { run: LoadedRun }) {
+ {/* Steering footer: live control lives in Strix Cloud. */} +
+

Drive the agents live

+

Steer and re-run this scan from the web.

+
+ + +
+
+ {selectedAgent && ( void; + activeRun: string | null; + auth: AuthStatus | null; + /** Re-fetch auth status after a successful verify (lifts state to App). */ + onVerified: () => void; +} + +const OTP_START_ERRORS: Record = { + rate_limited: "Too many requests. Wait a minute and try again.", + invalid_email: "That email does not look right. Check it and try again.", + unavailable: "The email service is unavailable right now. Try again shortly.", +}; + +const SEND_ERRORS: Record = { + forbidden: "This email was unsubscribed from Strix, so we cannot send to it.", + too_large: "This report is too large to email. Try a smaller run.", + unavailable: "The email service is unavailable right now. Try again shortly.", +}; + +export default function EmailReportDialog({ + open, + onClose, + activeRun, + auth, + onVerified, +}: EmailReportDialogProps) { + const verified = auth?.verified === true; + const [step, setStep] = useState("disclosure"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [password, setPassword] = useState(""); + const [filename, setFilename] = useState(""); + const [copied, setCopied] = useState(false); + const sentTo = useRef(""); + + // Reset to the disclosure step each time the dialog opens; prefill the email + // when already verified so the flow can skip straight to sending. + useEffect(() => { + if (!open) return; + setStep("disclosure"); + setCode(""); + setBusy(false); + setError(null); + setNotice(null); + setPassword(""); + setFilename(""); + setCopied(false); + setEmail(auth?.email ?? ""); + }, [open, auth?.email]); + + // Close on Escape (but never while a password is on screen: it must not be + // dismissed by accident). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && step !== "password") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, step, onClose]); + + if (!open) return null; + + const doSend = async () => { + setStep("sending"); + setError(null); + const result = await sendReport(activeRun); + if (result.ok) { + setPassword(result.password); + setFilename(result.filename); + setStep("password"); + return; + } + // A stale session needs a fresh OTP; unverified means we never had one. + if (result.error === "reverify" || result.error === "unverified") { + setNotice("Your verification expired. Enter your email to verify again."); + setStep("email"); + return; + } + setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again."); + setStep("disclosure"); + }; + + const startFlow = () => { + setError(null); + setNotice(null); + if (verified) { + void doSend(); + } else { + setStep("email"); + } + }; + + const submitEmail = async () => { + const value = email.trim(); + if (!value) { + setError("Enter your email to continue."); + return; + } + setBusy(true); + setError(null); + const result = await otpStart(value); + setBusy(false); + if (result.ok) { + setNotice(`We sent a 6-digit code to ${value}.`); + setStep("code"); + } else { + setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again."); + } + }; + + const submitCode = async () => { + const value = code.trim(); + if (value.length < 4) { + setError("Enter the 6-digit code from your email."); + return; + } + setBusy(true); + setError(null); + const result = await otpVerify(email.trim(), value); + setBusy(false); + if (result.verified) { + sentTo.current = result.email; + onVerified(); + void doSend(); + } else { + setError("That code did not match. Check it and try again."); + } + }; + + const copyPassword = async () => { + try { + await navigator.clipboard.writeText(password); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard may be unavailable; the password is visible to copy manually */ + } + }; + + const confirmationEmail = sentTo.current || auth?.email || email.trim(); + + return ( +
+
step !== "password" && onClose()} + /> +
+ + +
+
+
+
+

Email an encrypted PDF

+

Unlock with your email

+
+
+ + {error && ( +
+
+ )} + {notice && !error && step !== "password" && ( +

{notice}

+ )} + + {step === "disclosure" && ( +
+
+
+
+
+
+
+ + {verified && auth?.email && ( +

Sending to {auth.email}

+ )} +
+ )} + + {step === "email" && ( +
{ + e.preventDefault(); + void submitEmail(); + }} + > + + +
+ )} + + {step === "code" && ( +
{ + e.preventDefault(); + void submitCode(); + }} + > + + + +
+ )} + + {step === "sending" && ( +
+
+ )} + + {step === "password" && ( +
+
+
+
+ Your one-time password +
+ {password} + +
+

+ Save this now. Strix never stores it, so we cannot show it again. File:{" "} + {filename} +

+
+ +
+ )} +
+
+ ); +} diff --git a/strix/viewer_src/src/components/PastRunsView.tsx b/strix/viewer_src/src/components/PastRunsView.tsx new file mode 100644 index 00000000..1e69d4c9 --- /dev/null +++ b/strix/viewer_src/src/components/PastRunsView.tsx @@ -0,0 +1,141 @@ +import { History, ChevronRight, Terminal } from "lucide-react"; +import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource"; + +/** + * "Past runs" panel. Unverified users see a tease with the run count and a + * verify affordance (the launched run stays fully visible; the CLI + * `strix view ` still works). Verified users get the full history and can + * switch the active run, which threads ?run= through the data fetches. + */ + +const SEV = [ + { key: "critical", dot: "bg-red-500", text: "text-red-500" }, + { key: "high", dot: "bg-orange-500", text: "text-orange-500" }, + { key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" }, + { key: "low", dot: "bg-blue-500", text: "text-blue-500" }, +] as const; + +function SeverityChips({ counts }: { counts: RunSeverityCounts }) { + const shown = SEV.filter((s) => counts[s.key] > 0); + if (shown.length === 0) { + return No findings; + } + return ( +
+ {shown.map((s) => ( +
+
+ ))} +
+ ); +} + +function formatDate(iso: string | null): string | null { + if (!iso) return null; + const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T"); + const d = new Date(normalized); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +interface PastRunsViewProps { + runs: RunsPayload | null; + activeRun: string | null; + onSelectRun: (name: string) => void; + onVerifyClick: () => void; +} + +export default function PastRunsView({ + runs, + activeRun, + onSelectRun, + onVerifyClick, +}: PastRunsViewProps) { + const count = runs?.count ?? 0; + + if (!runs || runs.locked) { + return ( +
+
+
+

Browse every run on this machine

+

+ You have {count} past {count === 1 ? "run" : "runs"} on this machine. Verify your + email to browse them here. +

+ +

+

+
+ ); + } + + if (runs.runs.length === 0) { + return ( +
+ No past runs found on this machine yet. +
+ ); + } + + return ( +
+ {runs.runs.map((run: RunListEntry) => { + const active = run.name === activeRun; + const date = formatDate(run.start_time) ?? formatDate(run.end_time); + return ( + + ); + })} +
+ ); +}