Add the email report dialog, past runs view, and run switcher

This commit is contained in:
Jonathan Singer
2026-07-20 12:02:13 -04:00
parent 8d9f785dc7
commit 4c337f93ba
3 changed files with 894 additions and 152 deletions
+393 -152
View File
@@ -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<string | null>(null);
const [run, setRun] = useState<LoadedRun | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<"overview" | "issues" | "agents">("overview");
const [view, setView] = useState<View>("overview");
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [runs, setRuns] = useState<RunsPayload | null>(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=<name>) reloads its data; a finished run
// does a single full fetch and stops.
const finishedRef = useRef(false);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | 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 (
<div className="min-h-screen bg-black text-white">
{/* Top bar */}
<div className="border-b border-[#222]">
<div className="max-w-[88rem] mx-auto px-6 py-4 flex items-center gap-1.5">
<a
href="https://app.strix.ai"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo")}
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
title="Open Strix Cloud"
>
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
<div className="text-base text-white font-medium tracking-tight">Strix</div>
</a>
<span className="ml-3 text-xs text-[#666]">Local results</span>
{run && <LiveIndicator finished={run.finished} />}
</div>
</div>
<div className="min-h-screen bg-black text-white flex">
<Sidebar
view={view}
onSelectView={(v) => {
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()}
/>
<div className="max-w-[88rem] mx-auto px-6 py-8 space-y-6">
{/* Trust banner */}
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
</div>
{error && !run && (
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
<p className="text-sm text-red-300">{error}</p>
</div>
)}
{!run && !error && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
<p className="text-sm text-[#888]">Loading run data</p>
</div>
)}
{run && counts && (
<>
<SummaryHeader summary={run.summary} />
<UpsellRow />
<div className="flex gap-5 border-b border-[#2a2a2a]">
<TabButton active={view === "overview"} onClick={() => setView("overview")}>
Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => setView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => setView("agents")}>
Agents ({agentCount})
</TabButton>
<div className="flex-1 min-w-0">
{/* Top bar */}
<div className="border-b border-[#222]">
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
<a
href="https://app.strix.ai"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo")}
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
title="Open Strix Cloud"
>
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
<div className="text-base text-white font-medium tracking-tight">Strix</div>
</a>
<span className="text-xs text-[#666]">Local results</span>
{run && <LiveIndicator finished={run.finished} />}
<div className="ml-auto flex items-center gap-3">
{verified && runs && !runs.locked && runs.runs.length > 0 && (
<RunSwitcher
runs={runs}
activeRun={activeRun}
launchedName={run?.summary.runName ?? run?.summary.runId ?? "Current run"}
onSelect={selectRun}
/>
)}
<a
href={SIGNUP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("sidebar_start_free")}
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
Start free
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
</a>
</div>
</div>
</div>
{view === "overview" ? (
<OverviewTab
summary={run.summary}
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} />
) : selected ? (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" /> Back to all findings
</button>
<VulnerabilityDetail vulnerability={selected} />
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
{/* Trust banner */}
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
</div>
{error && !run && view !== "history" && (
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
<p className="text-sm text-red-300">{error}</p>
</div>
)}
{view === "history" ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
</div>
) : (
<FindingsList
vulnerabilities={run.vulnerabilities}
finished={run.finished}
onSelect={(id) => setSelectedId(id)}
<PastRunsView
runs={runs}
activeRun={activeRun}
onSelectRun={selectRun}
onVerifyClick={openEmail}
/>
)}
</>
)}
</div>
) : !run && !error ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
<p className="text-sm text-[#888]">Loading run data</p>
</div>
) : run && counts ? (
<>
<SummaryHeader summary={run.summary} />
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
<TabButton active={view === "overview"} onClick={() => setView("overview")}>
Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => setView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => setView("agents")}>
Agents ({agentCount})
</TabButton>
)}
</div>
{view === "overview" ? (
<OverviewTab
summary={run.summary}
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
onOpenEmail={openEmail}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} />
) : selected ? (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" /> Back to all findings
</button>
<VulnerabilityDetail vulnerability={selected} />
</div>
) : (
<FindingsList
vulnerabilities={run.vulnerabilities}
finished={run.finished}
onSelect={(id) => setSelectedId(id)}
/>
)}
</>
) : null}
</div>
</div>
<EmailReportDialog
open={emailOpen}
onClose={() => setEmailOpen(false)}
activeRun={activeRun}
auth={auth}
onVerified={() => {
void refreshAuth();
void refreshRuns();
}}
/>
</div>
);
}
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 (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
>
<History className="w-3.5 h-3.5" aria-hidden="true" />
<span className="max-w-[160px] truncate">{current}</span>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</button>
{open && (
<div
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
>
{runs.runs.map((r) => {
const active = r.name === activeRun;
return (
<button
key={r.name}
onMouseDown={() => onSelect(r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
active ? "text-white" : "text-[#aaa]"
}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{r.name}</span>
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
</span>
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -206,14 +408,14 @@ export default function App() {
function LiveIndicator({ finished }: { finished: boolean }) {
if (finished) {
return (
<span className="ml-auto inline-flex items-center gap-1.5 text-xs text-[#888]">
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
Complete
</span>
);
}
return (
<span className="ml-auto inline-flex items-center gap-1.5 text-xs text-emerald-400">
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
@@ -274,8 +476,24 @@ function FindingsList({
);
if (sorted.length === 0) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
<div className="space-y-4">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
</div>
{finished && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
<p className="mt-0.5 mb-3 text-xs text-[#666]">
Attack surface monitoring catches new exposures for your org over time.
</p>
<ProInlineCta
label="Attack surface monitoring"
desc="Continuous coverage for your whole org."
slug="asm"
icon={Radar}
/>
</div>
)}
</div>
);
}
@@ -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 (
<button
onClick={onOpenEmail}
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
>
<div className="flex items-center gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
>
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Email an encrypted PDF of this report</p>
<p className="mt-0.5 text-xs text-[#888]">
Email yourself an encrypted PDF of this report. Unlock with your email.
</p>
</div>
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
Email report
</span>
</div>
</button>
);
}
function OverviewTab({
summary,
counts,
total,
reportMarkdown,
onOpenEmail,
}: {
summary: ParsedRunSummary;
counts: Record<VulnerabilitySeverity, number>;
total: number;
reportMarkdown: string | null;
onOpenEmail: () => void;
}) {
const sections = (
[
@@ -356,6 +604,10 @@ function OverviewTab({
<IssueSeveritySummary findings={{ total, ...counts }} />
</div>
)}
{/* Primary CTA: the one primary on Overview. */}
<EmailReportCta onOpenEmail={onOpenEmail} />
{sections.length > 0 ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
{sections.map((s) => (
@@ -371,6 +623,23 @@ function OverviewTab({
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
)
)}
{/* Near Recommendations: act on the fixes. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{RECOMMENDATION_CTAS.map((item) => (
<ProTile key={item.slug} item={item} />
))}
</div>
{/* Continuous coverage for your org (restyled upsell tiles). */}
<div>
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{COVERAGE_CTAS.map((item) => (
<ProTile key={item.slug} item={item} />
))}
</div>
</div>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{UPSELLS.map((u) => {
const Icon = u.icon;
return (
<a
key={u.title}
href={SIGNUP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(u.feature)}
className="cursor-pointer text-left rounded-xl border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] p-4 transition-colors group block"
>
<div className="flex items-center justify-between mb-2">
<Icon className="w-4 h-4 text-[#888] group-hover:text-white transition-colors" />
<Lock className="w-3.5 h-3.5 text-[#555]" aria-hidden="true" />
</div>
<p className="text-sm font-medium text-white">{u.title}</p>
<p className="text-xs text-[#666] mt-0.5">{u.desc}</p>
</a>
);
})}
</div>
);
}
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 }) {
</div>
</div>
{/* Steering footer: live control lives in Strix Cloud. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Drive the agents live</p>
<p className="mt-0.5 text-xs text-[#666]">Steer and re-run this scan from the web.</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Steer agents from the web"
desc="Guide the agents live while they scan."
slug="live_scan_prompt"
icon={Radio}
/>
<ProInlineCta
label="Re-run in Strix Cloud with more depth"
desc="Run this scan on managed infra with more depth."
slug="live_scan"
icon={Rocket}
/>
</div>
</div>
{selectedAgent && (
<AgentDetailModal
agent={selectedAgent}
@@ -0,0 +1,360 @@
import { useEffect, useRef, useState } from "react";
import { X, Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle } from "lucide-react";
import {
otpStart,
otpVerify,
sendReport,
type AuthStatus,
} from "@/data/serverSource";
type Step = "disclosure" | "email" | "code" | "sending" | "password";
interface EmailReportDialogProps {
open: boolean;
onClose: () => 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<string, string> = {
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<string, string> = {
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<Step>("disclosure");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [filename, setFilename] = useState("");
const [copied, setCopied] = useState(false);
const sentTo = useRef<string>("");
// 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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Email an encrypted report"
>
<div
className="absolute inset-0 bg-black/70"
onClick={() => step !== "password" && onClose()}
/>
<div
className="relative z-10 w-full max-w-md rounded-2xl bg-[#0a0a0a] p-6 shadow-2xl"
style={{ border: "1px solid #2a2a2a" }}
>
<button
onClick={onClose}
className="absolute right-4 top-4 cursor-pointer text-[#666] transition-colors hover:text-white"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<div className="mb-4 flex items-center gap-2.5">
<div
className="flex h-9 w-9 items-center justify-center rounded-lg"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div>
<h2 className="text-base font-semibold text-white">Email an encrypted PDF</h2>
<p className="text-xs text-[#666]">Unlock with your email</p>
</div>
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{notice && !error && step !== "password" && (
<p className="mb-4 text-xs text-[#888]">{notice}</p>
)}
{step === "disclosure" && (
<div className="space-y-4">
<div
className="space-y-2.5 rounded-lg p-3.5"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<div className="flex items-start gap-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
Viewing stays local and nothing is uploaded. Emailing is an explicit
opt-in: we send an <span className="text-white">encrypted PDF</span>.
</p>
</div>
<div className="flex items-start gap-2.5">
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
The report is encrypted with a password that only you hold. Strix
cannot read it and never stores it. We collect only your email so we
can send it.
</p>
</div>
</div>
<button
onClick={startFlow}
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
</button>
{verified && auth?.email && (
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
)}
</div>
)}
{step === "email" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitEmail();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your email</span>
<input
type="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Send me a code
</button>
</form>
)}
{step === "code" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitCode();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
<input
inputMode="numeric"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Verify and send
</button>
<button
type="button"
onClick={() => {
setStep("email");
setError(null);
setNotice(null);
}}
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
>
Use a different email
</button>
</form>
)}
{step === "sending" && (
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
</div>
)}
{step === "password" && (
<div className="space-y-4">
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs text-emerald-200">
Sent to {confirmationEmail}. Open the attached PDF with this password.
</p>
</div>
<div>
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
<div
className="flex items-center gap-2 rounded-lg bg-black p-3"
style={{ border: "1px solid #2a2a2a" }}
>
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
<button
onClick={copyPassword}
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-2 text-xs text-[#666]">
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
<span className="font-mono text-[#888]">{filename}</span>
</p>
</div>
<button
onClick={onClose}
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
style={{ border: "1px solid #2a2a2a" }}
>
Done
</button>
</div>
)}
</div>
</div>
);
}
@@ -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 <name>` still works). Verified users get the full history and can
* switch the active run, which threads ?run=<name> 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 <span className="text-xs text-[#555]">No findings</span>;
}
return (
<div className="flex items-center gap-3">
{shown.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
</div>
))}
</div>
);
}
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 (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
<div
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
</div>
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
You have {count} past {count === 1 ? "run" : "runs"} on this machine. Verify your
email to browse them here.
</p>
<button
onClick={onVerifyClick}
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
Verify email to unlock
</button>
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
Or open one from the CLI with{" "}
<code className="font-mono text-[#888]">strix view &lt;name&gt;</code>
</p>
</div>
);
}
if (runs.runs.length === 0) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
No past runs found on this machine yet.
</div>
);
}
return (
<div className="space-y-2">
{runs.runs.map((run: RunListEntry) => {
const active = run.name === activeRun;
const date = formatDate(run.start_time) ?? formatDate(run.end_time);
return (
<button
key={run.name}
onClick={() => onSelectRun(run.name)}
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
active
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">{run.name}</span>
{active && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
Active
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
{run.target && <span className="truncate font-mono text-[#888]">{run.target}</span>}
{run.target && (run.scan_mode || date || run.status) && <span className="text-[#333]">·</span>}
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
{date && <span className="text-[#333]">·</span>}
{date && <span>{date}</span>}
{run.status && <span className="text-[#333]">·</span>}
{run.status && <span className="capitalize">{run.status}</span>}
</div>
</div>
<SeverityChips counts={run.severity_counts} />
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
</button>
);
})}
</div>
);
}