Polish the local viewer: inline verify, clean titles, smart default view

- Verify your email right on the Past runs page instead of being sent off
  to the Email report page; the list unlocks in place once verified.
- Show a clean target-based title (like arch.co) and a relative time
  instead of the raw run folder name everywhere runs are listed.
- Land on Agents while a scan is still live and on Overview once it is
  done, without ever overriding where you navigate yourself.
This commit is contained in:
Jonathan Singer
2026-07-20 16:48:52 -04:00
parent 8f4ca2aedc
commit 4465617134
4 changed files with 278 additions and 37 deletions
+55 -23
View File
@@ -39,6 +39,7 @@ import {
type RunsPayload,
} from "@/data/serverSource";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { runTitle } from "@/lib/target-utils";
import Sidebar from "@/components/Sidebar";
import PastRunsView from "@/components/PastRunsView";
import EmailReportView from "@/components/EmailReportView";
@@ -175,43 +176,73 @@ export default function App() {
const agentCount = run?.transcript.agents.length ?? 0;
const verified = auth?.verified === true;
// Per-run guard for the default view: land on Agents while a scan is live,
// Overview once it finishes. Applied at most once per run and never once the
// user has navigated manually (userSetView flips the guard).
const initialViewAppliedRef = useRef(false);
// Reset the guard whenever the active run changes so the newly selected run
// gets its own default.
useEffect(() => {
initialViewAppliedRef.current = false;
}, [activeRun]);
useEffect(() => {
if (initialViewAppliedRef.current || !run) return;
if (run.finished) {
initialViewAppliedRef.current = true;
setView("overview");
} else if (agentCount > 0) {
// Live and agents have appeared: default to the agent graph. If it is
// live but no agents exist yet, wait (do not apply, do not set the flag).
initialViewAppliedRef.current = true;
setView("agents");
}
}, [run, agentCount]);
// User-initiated navigation: mark the default guard applied so the per-run
// default effect never yanks the user off the view they chose.
const userSetView = useCallback((v: View) => {
initialViewAppliedRef.current = true;
setView(v);
}, []);
const selectRun = useCallback((name: string) => {
setActiveRun(name);
setSelectedId(null);
setRun(null);
setError(null);
setView("overview");
// Reset the guard so the per-run default applies to the newly selected run.
initialViewAppliedRef.current = false;
}, []);
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
trackCta("email_report", surface);
setEmailPurpose("report");
setEmailSkipDisclosure(skipDisclosure);
setView("email");
}, []);
userSetView("email");
}, [userSetView]);
// Sidebar entry keeps the disclosure (first place those users see it);
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
// the Overview CTA already states the tradeoff, so it starts the flow directly.
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
const openVerify = useCallback(() => {
trackCta("history_unlock", "past_runs");
setEmailPurpose("verify");
setEmailSkipDisclosure(false);
setView("email");
}, []);
const openHistory = useCallback(() => {
void refreshRuns();
setView("history");
}, [refreshRuns]);
userSetView("history");
}, [refreshRuns, userSetView]);
const onPastRunsVerified = useCallback(async () => {
await refreshAuth();
await refreshRuns();
}, [refreshAuth, refreshRuns]);
const selectFeature = useCallback((slug: string) => {
trackCta(slug, "sidebar_nav");
setActiveFeature(slug);
setView("feature");
}, []);
userSetView("feature");
}, [userSetView]);
const onForget = useCallback(async () => {
await forgetAuth();
@@ -225,7 +256,7 @@ export default function App() {
view={view}
onSelectView={(v) => {
if (v === "history") openHistory();
else setView(v);
else userSetView(v);
}}
activeFeature={activeFeature}
onSelectFeature={selectFeature}
@@ -261,7 +292,7 @@ export default function App() {
<RunSwitcher
runs={runs}
activeRun={activeRun}
launchedName={run?.summary.runName ?? run?.summary.runId ?? "Current run"}
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
onSelect={selectRun}
/>
)}
@@ -319,7 +350,7 @@ export default function App() {
runs={runs}
activeRun={activeRun}
onSelectRun={selectRun}
onVerifyClick={openVerify}
onVerified={() => void onPastRunsVerified()}
/>
</div>
) : !run && !error ? (
@@ -333,14 +364,14 @@ export default function App() {
{/* 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")}>
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => setView("issues")}>
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => setView("agents")}>
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
Agents ({agentCount})
</TabButton>
)}
@@ -393,7 +424,8 @@ function RunSwitcher({
onSelect: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
const current = activeRun ?? launchedName;
const activeEntry = runs.runs.find((r) => r.name === activeRun);
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
return (
<div className="relative">
<button
@@ -422,7 +454,7 @@ function RunSwitcher({
}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{r.name}</span>
<span className="block truncate">{runTitle(r.target, 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" />}
@@ -469,7 +501,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
return (
<div>
<h1 className="text-2xl font-semibold text-white">
{summary.runName ?? summary.runId ?? "Scan results"}
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
{summary.targets.length > 0 && (
@@ -0,0 +1,163 @@
import { useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { otpStart, otpVerify } from "@/data/serverSource";
import { track } from "@/lib/cta";
/**
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
* has no page chrome, no report send, and no password panel: it just confirms
* the email so the past-runs list can unlock in place. On success it calls
* `onVerified` (the parent refreshes auth + runs).
*/
const OTP_START_ERRORS: Record<string, string> = {
work_email_required: "Please use your work email, not a personal one.",
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.",
};
// A small set of common personal providers for instant client-side feedback.
// The relay is authoritative (it checks the full free-email-domains list).
const COMMON_FREE_DOMAINS = new Set([
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
"protonmail.com", "gmx.com", "mail.com",
]);
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
const [step, setStep] = useState<"email" | "code">("email");
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 submitEmail = async () => {
const value = email.trim();
if (!value) {
setError("Enter your email to continue.");
return;
}
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
if (COMMON_FREE_DOMAINS.has(domain)) {
track("work_email_required");
setError(OTP_START_ERRORS.work_email_required);
return;
}
setBusy(true);
setError(null);
const result = await otpStart(value);
setBusy(false);
if (result.ok) {
track("email_submitted", { purpose: "verify" });
setNotice(`We sent a 6-digit code to ${value}.`);
setStep("code");
} else {
if (result.error === "work_email_required") track("work_email_required");
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) {
setError("That code did not match. Check it and try again.");
return;
}
track("email_verified", { purpose: "verify" });
onVerified();
};
return (
<div className="mx-auto mt-5 max-w-sm text-left">
{error && (
<div className="mb-3 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 && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
{step === "email" ? (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submitEmail();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your work 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" }}
/>
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
</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>
) : (
<form
className="space-y-3"
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
</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>
)}
</div>
);
}
@@ -1,5 +1,9 @@
import { useState } from "react";
import { History, ChevronRight, Terminal } from "lucide-react";
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
import { runTitle } from "@/lib/target-utils";
import { trackCta } from "@/lib/cta";
import EmailVerifyInline from "@/components/EmailVerifyInline";
/**
* "Past runs" panel. Unverified users see a tease with the run count and a
@@ -45,20 +49,41 @@ function formatDate(iso: string | null): string | null {
});
}
/**
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
* the absolute date for anything older than a week (mirrors the pro app).
*/
function formatTimeAgo(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;
const diffMs = Date.now() - d.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return formatDate(iso);
}
interface PastRunsViewProps {
runs: RunsPayload | null;
activeRun: string | null;
onSelectRun: (name: string) => void;
onVerifyClick: () => void;
onVerified: () => void;
}
export default function PastRunsView({
runs,
activeRun,
onSelectRun,
onVerifyClick,
onVerified,
}: PastRunsViewProps) {
const count = runs?.count ?? 0;
const [showVerify, setShowVerify] = useState(false);
if (!runs || runs.locked) {
return (
@@ -73,12 +98,24 @@ export default function PastRunsView({
<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.
</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"
>
View runs
</button>
{showVerify ? (
<>
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
Verify your email with a one-time code to unlock the full history.
</p>
<EmailVerifyInline onVerified={onVerified} />
</>
) : (
<button
onClick={() => {
trackCta("history_unlock", "past_runs");
setShowVerify(true);
}}
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
View runs
</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{" "}
@@ -100,7 +137,8 @@ export default function PastRunsView({
<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);
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
const title = runTitle(run.target, run.name);
return (
<button
key={run.name}
@@ -113,7 +151,7 @@ export default function PastRunsView({
>
<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>
<span className="truncate text-sm font-medium text-white">{title}</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
@@ -121,12 +159,10 @@ export default function PastRunsView({
)}
</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>}
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
{date && <span>{date}</span>}
{run.status && <span className="text-[#333]">·</span>}
{date && run.status && <span className="text-[#333]">·</span>}
{run.status && <span className="capitalize">{run.status}</span>}
</div>
</div>
+10
View File
@@ -49,3 +49,13 @@ export function parseTarget(target: string): ParsedTarget {
// Not a URL
return { display: target, href: null, provider: null };
}
/**
* A clean, human-readable run title derived from the scan target (e.g.
* "arch.co") rather than the raw run dir name ("arch-co_ca3f"). Falls back to
* the raw name, then a generic label.
*/
export function runTitle(target: string | null, fallback: string): string {
if (target) return parseTarget(target).display.replace(/\/$/, "");
return fallback || "Untitled scan";
}