From d73f319be5ce75258ac28870ec841f677842870b Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Mon, 20 Jul 2026 13:31:13 -0400 Subject: [PATCH] Rework the viewer nav, add in-app feature pages, and verify-only past runs - Collapse the sidebar into one ordered list of uniform two-line rows (icon + label + short one-liner), no tier sections. - Sidebar Pro/Enterprise rows now open an in-app FeatureDetail upsell page instead of linking out; the page's primary CTA is the sign-up link. - Past runs "View runs" runs a verify-only flow that never sends a report. - Ask for a work email: helper text, an instant common-domain check, and a friendly message when the relay rejects a personal domain. --- strix/viewer_src/src/App.tsx | 29 ++- .../src/components/EmailReportDialog.tsx | 69 ++++++- .../src/components/FeatureDetail.tsx | 96 ++++++++++ .../src/components/PastRunsView.tsx | 5 +- strix/viewer_src/src/components/ProCta.tsx | 52 +++--- strix/viewer_src/src/components/Sidebar.tsx | 113 +++++------- strix/viewer_src/src/lib/pro-features.ts | 174 ++++++++++++++++++ 7 files changed, 436 insertions(+), 102 deletions(-) create mode 100644 strix/viewer_src/src/components/FeatureDetail.tsx create mode 100644 strix/viewer_src/src/lib/pro-features.ts diff --git a/strix/viewer_src/src/App.tsx b/strix/viewer_src/src/App.tsx index f7b17fce..d84d536d 100644 --- a/strix/viewer_src/src/App.tsx +++ b/strix/viewer_src/src/App.tsx @@ -42,9 +42,11 @@ import { SIGNUP_URL, trackCta } from "@/lib/cta"; import Sidebar from "@/components/Sidebar"; import PastRunsView from "@/components/PastRunsView"; import EmailReportDialog from "@/components/EmailReportDialog"; +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"; +export type View = "overview" | "issues" | "agents" | "history" | "feature"; 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."; @@ -70,9 +72,11 @@ export default function App() { const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); const [view, setView] = useState("overview"); + const [activeFeature, setActiveFeature] = useState(null); const [auth, setAuth] = useState(null); const [runs, setRuns] = useState(null); const [emailOpen, setEmailOpen] = useState(false); + const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report"); const refreshAuth = useCallback(async () => { try { @@ -181,15 +185,27 @@ export default function App() { const openEmail = useCallback(() => { trackCta("email_report"); + setEmailPurpose("report"); + setEmailOpen(true); + }, []); + + const openVerify = useCallback(() => { + trackCta("history_unlock"); + setEmailPurpose("verify"); setEmailOpen(true); }, []); const openHistory = useCallback(() => { - trackCta("history_unlock"); void refreshRuns(); setView("history"); }, [refreshRuns]); + const selectFeature = useCallback((slug: string) => { + trackCta(`nav_${slug}`); + setActiveFeature(slug); + setView("feature"); + }, []); + const onForget = useCallback(async () => { await forgetAuth(); await refreshAuth(); @@ -204,6 +220,8 @@ export default function App() { if (v === "history") openHistory(); else setView(v); }} + activeFeature={activeFeature} + onSelectFeature={selectFeature} issuesCount={run?.vulnerabilities.length ?? 0} agentCount={agentCount} runCount={runs?.count ?? 0} @@ -268,7 +286,9 @@ export default function App() { )} - {view === "history" ? ( + {view === "feature" && activeFeature && FEATURES[activeFeature] ? ( + + ) : view === "history" ? (
) : !run && !error ? ( @@ -342,6 +362,7 @@ export default function App() { onClose={() => setEmailOpen(false)} activeRun={activeRun} auth={auth} + purpose={emailPurpose} onVerified={() => { void refreshAuth(); void refreshRuns(); diff --git a/strix/viewer_src/src/components/EmailReportDialog.tsx b/strix/viewer_src/src/components/EmailReportDialog.tsx index 40069f5f..7655a555 100644 --- a/strix/viewer_src/src/components/EmailReportDialog.tsx +++ b/strix/viewer_src/src/components/EmailReportDialog.tsx @@ -9,11 +9,19 @@ import { 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; activeRun: string | null; auth: AuthStatus | null; + purpose: Purpose; /** Re-fetch auth status after a successful verify (lifts state to App). */ onVerified: () => void; } @@ -21,9 +29,34 @@ interface EmailReportDialogProps { 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.", + 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 = { 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.", @@ -35,6 +68,7 @@ export default function EmailReportDialog({ onClose, activeRun, auth, + purpose, onVerified, }: EmailReportDialogProps) { const verified = auth?.verified === true; @@ -49,11 +83,12 @@ export default function EmailReportDialog({ 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. + // 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("disclosure"); + setStep(purpose === "verify" ? "email" : "disclosure"); setCode(""); setBusy(false); setError(null); @@ -62,7 +97,7 @@ export default function EmailReportDialog({ setFilename(""); setCopied(false); setEmail(auth?.email ?? ""); - }, [open, auth?.email]); + }, [open, auth?.email, purpose]); // Close on Escape (but never while a password is on screen: it must not be // dismissed by accident). @@ -113,6 +148,12 @@ 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)) { + setError(OTP_START_ERRORS.work_email_required); + return; + } setBusy(true); setError(null); const result = await otpStart(value); @@ -138,6 +179,11 @@ export default function EmailReportDialog({ 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 { setError("That code did not match. Check it and try again."); @@ -187,8 +233,16 @@ export default function EmailReportDialog({
-

Email an encrypted PDF report of this run

-

Verified by a one-time code sent to your email

+

+ {purpose === "verify" + ? "Verify your email to view your runs" + : "Email an encrypted PDF report of this run"} +

+

+ {purpose === "verify" + ? "We send a one-time code to confirm it is you." + : "Verified by a one-time code sent to your email"} +

@@ -255,6 +309,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" }} /> + Use your work email.

+ {/* One single ordered list, no section headers. */} +
onSelectView("overview")} /> 0 ? issuesCount : undefined} active={view === "issues"} onClick={() => onSelectView("issues")} @@ -138,64 +120,56 @@ export default function Sidebar({ onSelectView("agents")} /> )} -
- - {/* Your runs (local, unlock with email) */} -
0 ? runCount : undefined} active={view === "history"} onClick={onOpenHistory} /> - -
+ - {/* Platform (Pro link-outs) */} -
-

- Platform -

-

- Get the most out of Strix for your team. -

-
- {PLATFORM_ITEMS.map((item) => ( - - ))} -
+ {PLATFORM_ORDER.map((slug) => { + const feature = FEATURES[slug]; + if (!feature) return null; + return ( + onSelectFeature(slug)} + /> + ); + })}
); } -function Section({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-

- {label} -

-
{children}
-
- ); -} - function NavItem({ icon: Icon, label, + desc, count, active, onClick, }: { icon: React.ElementType; label: string; + desc: string; count?: number; active?: boolean; onClick: () => void; @@ -203,16 +177,21 @@ function NavItem({ return ( ); } diff --git a/strix/viewer_src/src/lib/pro-features.ts b/strix/viewer_src/src/lib/pro-features.ts new file mode 100644 index 00000000..0d7977f1 --- /dev/null +++ b/strix/viewer_src/src/lib/pro-features.ts @@ -0,0 +1,174 @@ +import type React from "react"; +import { + GitPullRequest, + Layers, + Globe, + Puzzle, + Users, + Search, + LayoutDashboard, + AlertTriangle, + MessageSquare, + Network, + Database, +} from "lucide-react"; + +/** + * Platform (Pro / Enterprise) feature catalog. Powers both the unified sidebar + * nav rows and the in-app FeatureDetail upsell view. Everything is "Pro" except + * Networks, which is "Enterprise". No lock icons anywhere. + */ + +export type FeatureTier = "Pro" | "Enterprise"; + +export interface ProFeature { + slug: string; + title: string; + icon: React.ElementType; + tier: FeatureTier; + /** Short one-liner for the sidebar nav row (two-line layout). */ + navDesc: string; + /** Headline shown on the FeatureDetail upsell page. */ + headline: string; + /** Longer sentence shown on the FeatureDetail upsell page. */ + description: string; +} + +// Flat catalog of every platform feature, keyed by slug for routing. The +// sidebar groups these into capability themes (see PLATFORM_THEMES); nothing +// here implies a tier ordering. +export const PLATFORM_FEATURES: ProFeature[] = [ + { + slug: "pr_reviews", + title: "PR Reviews", + icon: GitPullRequest, + tier: "Pro", + navDesc: "Pentest every pull request", + headline: "Pentest every pull request", + description: + "Strix reviews every pull request your team opens and catches exploitable changes before they merge.", + }, + { + slug: "repositories", + title: "Repositories", + icon: Layers, + tier: "Pro", + navDesc: "Connect your team's repos", + headline: "Connect your team's repositories", + description: + "Link your org's repositories so Strix can scan them continuously and track findings over time.", + }, + { + slug: "domains", + title: "Domains", + icon: Globe, + tier: "Pro", + navDesc: "Cover the domains you own", + headline: "Cover every domain you own", + description: + "Add the domains your team owns and let Strix watch them for newly exposed paths and drift.", + }, + { + slug: "integrations", + title: "Integrations", + icon: Puzzle, + tier: "Pro", + navDesc: "Sync to Jira, Linear, Slack", + headline: "Sync findings to your tools", + description: + "Two-way sync findings to Jira, Linear, and Slack so fixes happen where your team already works.", + }, + { + slug: "members", + title: "Members", + icon: Users, + tier: "Pro", + navDesc: "Invite your team, set roles", + headline: "Bring your whole team", + description: + "Invite your team, set roles, and share findings and run history across your org.", + }, + { + slug: "pentests", + title: "Pentests", + icon: Search, + tier: "Pro", + navDesc: "Deeper scans on managed infra", + headline: "Launch deeper pentests", + description: + "Run deeper, longer pentests on managed infrastructure whenever you need them.", + }, + { + slug: "dashboard", + title: "Dashboard", + icon: LayoutDashboard, + tier: "Pro", + navDesc: "Everything in one place", + headline: "See everything in one place", + description: + "Track every project, run, and finding across your org from a single dashboard.", + }, + { + slug: "platform_issues", + title: "Issues", + icon: AlertTriangle, + tier: "Pro", + navDesc: "Triage across your org", + headline: "Triage findings across your org", + description: + "Manage and triage findings across every project and repository in one queue.", + }, + { + slug: "chat", + title: "Chat", + icon: MessageSquare, + tier: "Pro", + navDesc: "Ask about any finding", + headline: "Ask Strix anything", + description: + "Ask the agents about any finding, run, or part of your app in natural language.", + }, + { + slug: "networks", + title: "Networks", + icon: Network, + tier: "Enterprise", + navDesc: "Reach internal, VPN-only targets", + headline: "Scan internal networks", + description: + "Connect private networks to scan internal applications, VPN-only services, and RFC1918 targets.", + }, + { + slug: "knowledge", + title: "Knowledge", + icon: Database, + tier: "Pro", + navDesc: "Give agents context", + headline: "Give agents context", + description: + "Teach Strix about your systems and business logic so every run gets smarter.", + }, +]; + +export const FEATURES: Record = Object.fromEntries( + PLATFORM_FEATURES.map((f) => [f.slug, f]) +); + +/** + * Order the platform rows appear in the sidebar's single, ungrouped nav list + * (after the run/local rows). No section headers; tier is shown only by each + * row's inline tag. + */ +export const PLATFORM_ORDER: string[] = [ + "pr_reviews", + "repositories", + "domains", + "integrations", + "members", + "pentests", + "networks", + "chat", + "dashboard", + "platform_issues", + "knowledge", +];