mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 18:52:47 +02:00
Move the Vite frontend source into strix/viewer/frontend and rename the built bundle to strix/viewer/static, so all the viewer's pieces live under one directory. Updated the server bundle path, vite outDir, packaging (pyproject exclude, PyInstaller spec), Makefile, gitignore, and docs to match. Avoided naming the bundle dir 'dist' since gitignore matches that unanchored and would drop the shipped bundle.
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
|
import {
|
|
otpStart,
|
|
otpVerify,
|
|
sendReport,
|
|
type AuthStatus,
|
|
} from "@/data/serverSource";
|
|
import { track } from "@/lib/cta";
|
|
|
|
/**
|
|
* 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";
|
|
|
|
interface EmailReportViewProps {
|
|
activeRun: string | null;
|
|
auth: AuthStatus | null;
|
|
purpose: "report" | "verify";
|
|
/**
|
|
* Skip the report disclosure and start the flow directly (used by the
|
|
* Overview CTA, which already states the tradeoff). Unverified users land on
|
|
* the email step; already-verified users send immediately.
|
|
*/
|
|
skipDisclosure?: boolean;
|
|
/** 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.",
|
|
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.",
|
|
};
|
|
|
|
// 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,
|
|
skipDisclosure = false,
|
|
onAuthChanged,
|
|
onExit,
|
|
}: EmailReportViewProps) {
|
|
const verified = auth?.verified === true;
|
|
const verifyOnly = purpose === "verify";
|
|
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
|
|
// email step; a verified user who skips the disclosure sends immediately.
|
|
const [step, setStep] = useState<Step>(() => {
|
|
if (verifyOnly) return "email";
|
|
if (skipDisclosure) return verified ? "sending" : "email";
|
|
return "disclosure";
|
|
});
|
|
const [email, setEmail] = useState(auth?.email ?? "");
|
|
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, setSentTo] = useState("");
|
|
const autoSentRef = useRef(false);
|
|
|
|
const doSend = async () => {
|
|
setStep("sending");
|
|
setError(null);
|
|
const result = await sendReport(activeRun);
|
|
if (result.ok) {
|
|
track("report_sent");
|
|
setPassword(result.password);
|
|
setFilename(result.filename);
|
|
setStep("password");
|
|
return;
|
|
}
|
|
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");
|
|
};
|
|
|
|
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
|
|
useEffect(() => {
|
|
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
|
|
autoSentRef.current = true;
|
|
void doSend();
|
|
}
|
|
// Run once on mount; the page remounts fresh each time it is opened.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
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 });
|
|
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 });
|
|
setSentTo(result.email);
|
|
onAuthChanged();
|
|
if (verifyOnly) onExit("history");
|
|
else void doSend();
|
|
};
|
|
|
|
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 || auth?.email || email.trim();
|
|
|
|
return (
|
|
<div className="mx-auto max-w-md 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="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
|
style={{ border: "1px solid #2a2a2a" }}
|
|
>
|
|
<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">
|
|
<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 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>
|
|
)}
|
|
|
|
{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" />}
|
|
{verifyOnly ? "Verify" : "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={() => 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" }}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|