Make the email report its own page instead of a modal

This commit is contained in:
Jonathan Singer
2026-07-20 13:41:34 -04:00
parent f91a831761
commit 2a85e27dc0
7 changed files with 225 additions and 295 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-BFNT20u6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BXJCfPCf.css">
<script type="module" crossorigin src="./assets/index-W1c8UAhM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Cldwq1Im.css">
</head>
<body>
<div id="root"></div>
+19 -21
View File
@@ -41,12 +41,12 @@ import {
import { SIGNUP_URL, trackCta } from "@/lib/cta";
import Sidebar from "@/components/Sidebar";
import PastRunsView from "@/components/PastRunsView";
import EmailReportDialog from "@/components/EmailReportDialog";
import EmailReportView from "@/components/EmailReportView";
import FeatureDetail from "@/components/FeatureDetail";
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
import { FEATURES } from "@/lib/pro-features";
export type View = "overview" | "issues" | "agents" | "history" | "feature";
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
const TRUST_BANNER =
"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.";
@@ -75,7 +75,6 @@ export default function App() {
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [runs, setRuns] = useState<RunsPayload | null>(null);
const [emailOpen, setEmailOpen] = useState(false);
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
const refreshAuth = useCallback(async () => {
@@ -186,13 +185,13 @@ export default function App() {
const openEmail = useCallback(() => {
trackCta("email_report");
setEmailPurpose("report");
setEmailOpen(true);
setView("email");
}, []);
const openVerify = useCallback(() => {
trackCta("history_unlock");
setEmailPurpose("verify");
setEmailOpen(true);
setView("email");
}, []);
const openHistory = useCallback(() => {
@@ -273,22 +272,33 @@ export default function App() {
</div>
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
{/* Trust banner (not on the Pro feature upsell pages) */}
{view !== "feature" && (
{/* Trust banner (not on the Pro feature or email pages) */}
{view !== "feature" && view !== "email" && (
<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" && (
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
<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 === "feature" && activeFeature && FEATURES[activeFeature] ? (
{view === "email" ? (
<EmailReportView
activeRun={activeRun}
auth={auth}
purpose={emailPurpose}
onAuthChanged={() => {
void refreshAuth();
void refreshRuns();
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
<FeatureDetail feature={FEATURES[activeFeature]} />
) : view === "history" ? (
<div className="space-y-4">
@@ -358,18 +368,6 @@ export default function App() {
) : null}
</div>
</div>
<EmailReportDialog
open={emailOpen}
onClose={() => setEmailOpen(false)}
activeRun={activeRun}
auth={auth}
purpose={emailPurpose}
onVerified={() => {
void refreshAuth();
void refreshRuns();
}}
/>
</div>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { X, Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle } from "lucide-react";
import { useState } from "react";
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
import {
otpStart,
otpVerify,
@@ -7,73 +7,59 @@ import {
type AuthStatus,
} from "@/data/serverSource";
/**
* The email-report / email-verification flow rendered as its own page (not a
* modal, so it never floats over another surface). Report mode ends in the
* one-time password panel; verify mode just confirms the email and returns to
* the caller. The page unmounts when you navigate away, so state resets each
* time it is opened.
*/
type Step = "disclosure" | "email" | "code" | "sending" | "password";
/**
* "report" runs the full disclosure -> verify -> encrypted send flow.
* "verify" is a verify-only flow (view past runs): it starts at the email step
* and finishes as soon as the code is confirmed, without sending a report.
*/
type Purpose = "report" | "verify";
interface EmailReportDialogProps {
open: boolean;
onClose: () => void;
interface EmailReportViewProps {
activeRun: string | null;
auth: AuthStatus | null;
purpose: Purpose;
/** Re-fetch auth status after a successful verify (lifts state to App). */
onVerified: () => void;
purpose: "report" | "verify";
/** Refresh auth + runs after a successful verify (lifts state to App). */
onAuthChanged: () => void;
/** Leave this page (report "Done" -> overview; verify success -> history). */
onExit: (dest: "overview" | "history") => void;
}
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.",
work_email_required: "Please use your work email, not a personal one.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
// Small common set for instant UX only; the relay is authoritative on the full
// free/personal domain 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",
]);
function isCommonFreeEmail(email: string): boolean {
const domain = email.split("@")[1]?.trim().toLowerCase();
return domain != null && COMMON_FREE_DOMAINS.has(domain);
}
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,
// 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 EmailReportView({
activeRun,
auth,
purpose,
onVerified,
}: EmailReportDialogProps) {
onAuthChanged,
onExit,
}: EmailReportViewProps) {
const verified = auth?.verified === true;
const [step, setStep] = useState<Step>("disclosure");
const [email, setEmail] = useState("");
const verifyOnly = purpose === "verify";
// Verify mode skips the report disclosure and goes straight to the email step.
const [step, setStep] = useState<Step>(verifyOnly ? "email" : "disclosure");
const [email, setEmail] = useState(auth?.email ?? "");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,36 +67,7 @@ export default function EmailReportDialog({
const [password, setPassword] = useState("");
const [filename, setFilename] = useState("");
const [copied, setCopied] = useState(false);
const sentTo = useRef<string>("");
// Reset each time the dialog opens. The report flow opens on the disclosure
// step; the verify-only flow skips it and starts straight at the email step.
// Prefill the email when already verified so the flow can skip ahead.
useEffect(() => {
if (!open) return;
setStep(purpose === "verify" ? "email" : "disclosure");
setCode("");
setBusy(false);
setError(null);
setNotice(null);
setPassword("");
setFilename("");
setCopied(false);
setEmail(auth?.email ?? "");
}, [open, auth?.email, purpose]);
// 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 [sentTo, setSentTo] = useState("");
const doSend = async () => {
setStep("sending");
@@ -122,7 +79,6 @@ export default function EmailReportDialog({
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");
@@ -135,11 +91,8 @@ export default function EmailReportDialog({
const startFlow = () => {
setError(null);
setNotice(null);
if (verified) {
void doSend();
} else {
setStep("email");
}
if (verified) void doSend();
else setStep("email");
};
const submitEmail = async () => {
@@ -148,9 +101,8 @@ export default function EmailReportDialog({
setError("Enter your email to continue.");
return;
}
// Snappy client-side guard for the obvious personal domains; the relay is
// still authoritative on the full list.
if (isCommonFreeEmail(value)) {
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
if (COMMON_FREE_DOMAINS.has(domain)) {
setError(OTP_START_ERRORS.work_email_required);
return;
}
@@ -176,18 +128,14 @@ export default function EmailReportDialog({
setError(null);
const result = await otpVerify(email.trim(), value);
setBusy(false);
if (result.verified) {
sentTo.current = result.email;
onVerified();
// Verify-only flow (viewing past runs) stops here; no report is sent.
if (purpose === "verify") {
onClose();
return;
}
void doSend();
} else {
if (!result.verified) {
setError("That code did not match. Check it and try again.");
return;
}
setSentTo(result.email);
onAuthChanged();
if (verifyOnly) onExit("history");
else void doSend();
};
const copyPassword = async () => {
@@ -200,51 +148,34 @@ export default function EmailReportDialog({
}
};
const confirmationEmail = sentTo.current || auth?.email || email.trim();
const confirmationEmail = sentTo || 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="space-y-4">
<button
onClick={() => onExit(verifyOnly ? "history" : "overview")}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
{verifyOnly ? "Back to past runs" : "Back to results"}
</button>
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">
{verifyOnly ? "Verify your email" : "Email report"}
</h1>
</div>
<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"
className="max-w-md rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
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">
{purpose === "verify"
? "Verify your email to view your runs"
: "Email an encrypted PDF report of this run"}
</h2>
<p className="text-xs text-[#666]">
{purpose === "verify"
? "We send a one-time code to confirm it is you."
: "Verified by a one-time code sent to your email"}
</p>
</div>
</div>
<p className="mb-4 text-xs text-[#666]">
{verifyOnly
? "We send a one-time code to confirm it is you."
: "Verified by a one-time code sent to your email"}
</p>
{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">
@@ -299,7 +230,7 @@ export default function EmailReportDialog({
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your email</span>
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
<input
type="email"
autoFocus
@@ -309,7 +240,7 @@ export default function EmailReportDialog({
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-xs text-[#666]">Use your work email.</span>
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
</label>
<button
type="submit"
@@ -348,7 +279,7 @@ export default function EmailReportDialog({
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" />}
{purpose === "verify" ? "Verify" : "Verify and send"}
{verifyOnly ? "Verify" : "Verify and send"}
</button>
<button
type="button"
@@ -401,7 +332,7 @@ export default function EmailReportDialog({
</p>
</div>
<button
onClick={onClose}
onClick={() => onExit("overview")}
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" }}
>
@@ -138,6 +138,7 @@ export default function Sidebar({
icon={Mail}
label="Email report"
desc="Get an encrypted PDF by email"
active={view === "email"}
onClick={onOpenEmail}
/>