mirror of
https://github.com/usestrix/strix.git
synced 2026-08-17 01:29:42 +02:00
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.
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
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 () => {
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "history" ? (
|
||||
{view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : 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" />
|
||||
@@ -278,7 +298,7 @@ export default function App() {
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
onSelectRun={selectRun}
|
||||
onVerifyClick={openEmail}
|
||||
onVerifyClick={openVerify}
|
||||
/>
|
||||
</div>
|
||||
) : !run && !error ? (
|
||||
@@ -342,6 +362,7 @@ export default function App() {
|
||||
onClose={() => setEmailOpen(false)}
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
onVerified={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
|
||||
@@ -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<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.",
|
||||
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.",
|
||||
@@ -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<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.
|
||||
// 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({
|
||||
<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 report of this run</h2>
|
||||
<p className="text-xs text-[#666]">Verified by a one-time code sent to your email</p>
|
||||
<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>
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -293,7 +348,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" />}
|
||||
Verify and send
|
||||
{purpose === "verify" ? "Verify" : "Verify and send"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
/**
|
||||
* In-app upsell page for a single platform feature. Modeled on the cloud app's
|
||||
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
|
||||
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
|
||||
* then a primary sign-up CTA and a secondary link to all plans.
|
||||
*/
|
||||
|
||||
const INCLUDED = [
|
||||
{
|
||||
icon: CalendarClock,
|
||||
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
|
||||
},
|
||||
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
|
||||
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
|
||||
{ icon: Users, text: "Your whole team, with roles and shared history" },
|
||||
];
|
||||
|
||||
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<ProTag label={feature.tier} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
|
||||
|
||||
<div
|
||||
className="mt-6 rounded-xl p-4 text-left"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
|
||||
Included in Strix Pro
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{INCLUDED.map((item) => {
|
||||
const BulletIcon = item.icon;
|
||||
return (
|
||||
<li key={item.text} className="flex items-start gap-2.5">
|
||||
<BulletIcon
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-[#aaa]">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug)}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={PRICING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(`${feature.slug}_pricing`)}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,14 +71,13 @@ export default function PastRunsView({
|
||||
</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.
|
||||
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"
|
||||
>
|
||||
Verify email to unlock
|
||||
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" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
|
||||
/**
|
||||
* Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
|
||||
@@ -9,14 +10,14 @@ import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
* row, and the inline CTAs in the tabs.
|
||||
*/
|
||||
|
||||
/** Small "Pro" pill. Deliberately not a padlock. */
|
||||
export function ProTag({ className = "" }: { className?: string }) {
|
||||
/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
|
||||
export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${className}`}
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
Pro
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -93,31 +94,40 @@ export function ProTile({ item }: { item: ProItem }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-row Pro item: icon + label + Pro tag, with the one-liner shown as
|
||||
* always-visible secondary text under the title. Link-out to sign-up in a new
|
||||
* tab.
|
||||
* Sidebar-row Pro item: a two-line row (icon + label + short one-liner
|
||||
* underneath) with a small right-aligned tier tag. Opens the in-app
|
||||
* FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
|
||||
* run/local rows in the themed nav list.
|
||||
*/
|
||||
export function ProNavItem({ item }: { item: ProItem }) {
|
||||
const Icon = item.icon;
|
||||
export function ProNavItem({
|
||||
feature,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
feature: ProFeature;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug)}
|
||||
className="group flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#666] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm text-[#aaa] transition-colors group-hover:text-white">
|
||||
{item.title}
|
||||
</span>
|
||||
<ProTag />
|
||||
<span className="flex-1 truncate text-sm">{feature.title}</span>
|
||||
<ProTag label={feature.tier} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{item.desc}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,50 +5,28 @@ import {
|
||||
Waypoints,
|
||||
History,
|
||||
Mail,
|
||||
GitPullRequest,
|
||||
Search,
|
||||
Layers,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Database,
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem, type ProItem } from "@/components/ProCta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail. Mirrors the cloud app's nav: real in-page content
|
||||
* ("This run"), local email-gated features ("Your runs"), and org-oriented
|
||||
* Pro link-outs ("Platform"). Matches App.tsx's dark palette.
|
||||
* Persistent left rail. A single, ungrouped, ordered list of uniform two-line
|
||||
* rows (icon + label + short one-liner): the current run's views, the local
|
||||
* run-history + email-report actions, then the platform features. No section
|
||||
* headers. Tier is shown only by the inline Pro/Enterprise tag on platform
|
||||
* rows. Matches App.tsx's dark palette.
|
||||
*/
|
||||
|
||||
// Org-signal items lead the list (PR Reviews, Repositories, Domains,
|
||||
// Integrations, Members), then the rest of the platform surface.
|
||||
const PLATFORM_ITEMS: ProItem[] = [
|
||||
{ title: "PR Reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
|
||||
{ title: "Repositories", desc: "Connect your org's repositories.", slug: "repositories", icon: Layers },
|
||||
{ title: "Domains", desc: "Add the domains your team owns.", slug: "domains", icon: Globe },
|
||||
{ title: "Integrations", desc: "Two-way sync findings to Jira, Linear, and Slack.", slug: "integrations", icon: Puzzle },
|
||||
{ title: "Members", desc: "Invite your team and set roles.", slug: "members", icon: Users },
|
||||
{ title: "Pentests", desc: "Launch deeper pentests on managed infra.", slug: "pentests", icon: Search },
|
||||
{ title: "Dashboard", desc: "See every project and finding in one place.", slug: "dashboard", icon: LayoutDashboard },
|
||||
{ title: "Issues", desc: "Track and triage findings across your org.", slug: "platform_issues", icon: AlertTriangle },
|
||||
{ title: "Chat", desc: "Ask the agents about any finding.", slug: "chat", icon: MessageSquare },
|
||||
{ title: "Networks", desc: "Map and monitor your network exposure.", slug: "networks", icon: Network },
|
||||
{ title: "Knowledge", desc: "Give agents context about your systems.", slug: "knowledge", icon: Database },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
activeFeature: string | null;
|
||||
onSelectFeature: (slug: string) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
@@ -62,6 +40,8 @@ interface SidebarProps {
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
activeFeature,
|
||||
onSelectFeature,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
@@ -119,17 +99,19 @@ export default function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* This run */}
|
||||
<Section label="This run">
|
||||
{/* One single ordered list, no section headers. */}
|
||||
<div className="mt-6 space-y-0.5">
|
||||
<NavItem
|
||||
icon={FileText}
|
||||
label="Overview"
|
||||
desc="This run's executive report"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Bug}
|
||||
label="Issues"
|
||||
desc="Findings from this run"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
@@ -138,64 +120,56 @@ export default function Sidebar({
|
||||
<NavItem
|
||||
icon={Waypoints}
|
||||
label="Agents"
|
||||
desc="What each agent did"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Your runs (local, unlock with email) */}
|
||||
<Section label="Your runs">
|
||||
<NavItem
|
||||
icon={History}
|
||||
label="Past runs"
|
||||
desc="Every run on this machine"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
<NavItem icon={Mail} label="Email report" onClick={onOpenEmail} />
|
||||
</Section>
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
|
||||
{/* Platform (Pro link-outs) */}
|
||||
<div className="mt-6">
|
||||
<p className="px-2.5 pb-1 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Platform
|
||||
</p>
|
||||
<p className="px-2.5 pb-2 text-[11px] leading-snug text-[#666]">
|
||||
Get the most out of Strix for your team.
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{PLATFORM_ITEMS.map((item) => (
|
||||
<ProNavItem key={item.slug} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{PLATFORM_ORDER.map((slug) => {
|
||||
const feature = FEATURES[slug];
|
||||
if (!feature) return null;
|
||||
return (
|
||||
<ProNavItem
|
||||
key={slug}
|
||||
feature={feature}
|
||||
active={view === "feature" && activeFeature === slug}
|
||||
onClick={() => onSelectFeature(slug)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="px-2.5 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
{label}
|
||||
</p>
|
||||
<div className="space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{label}</span>
|
||||
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, ProFeature> = 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",
|
||||
];
|
||||
Reference in New Issue
Block a user