mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d8db53fe | ||
|
|
d91e91129f | ||
|
|
318b54ccfc | ||
|
|
b0a0363754 | ||
|
|
ea6d7bab32 | ||
|
|
4524691355 |
+4
-12
@@ -16,7 +16,8 @@ RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \
|
||||
go install -v github.com/ropnop/kerbrute@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
@@ -40,7 +41,7 @@ RUN mkdir -p /home/pentester/tools /app/certs && \
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
wget curl git nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
@@ -83,8 +84,6 @@ USER root
|
||||
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
||||
update-ca-certificates
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
@@ -154,14 +153,7 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
|
||||
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
|
||||
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
|
||||
# "cannot move binary" and aborts the scan. Pin the initial version for
|
||||
# reproducible builds; self-update then pulls fresh detectors at runtime.
|
||||
ARG TRUFFLEHOG_VERSION=3.95.9
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
|
||||
chown -R pentester:pentester /home/pentester/.local
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.3.0"
|
||||
version = "1.2.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -449,10 +449,10 @@ PROXY & INTERCEPTION:
|
||||
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
||||
|
||||
PROGRAMMING:
|
||||
- Python 3, uv, Node.js/npm
|
||||
- Python 3, Node.js/npm
|
||||
- Full development environment
|
||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.). The Go toolchain is not bundled, so `go install` is unavailable; the Go-based scanners are prebuilt and already on PATH.
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
|
||||
@@ -213,7 +213,7 @@ pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE
|
||||
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
|
||||
pipx install bloodyAD # DACL / LDAP object edits over LDAP
|
||||
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
|
||||
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
|
||||
# kerbrute (Go, user enum / pre-auth brute) is prebuilt and already on PATH
|
||||
|
||||
# Kali apt packages
|
||||
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
|
||||
|
||||
@@ -98,12 +98,11 @@ The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
|
||||
`requests`, `httpx`, `beautifulsoup4` (`bs4`), `lxml`, `pyjwt` (`jwt`),
|
||||
`cryptography`.
|
||||
|
||||
To add a one-off dependency for an exploit script, use `uv` (already in the
|
||||
image and much faster than pip):
|
||||
To add a one-off dependency for an exploit script, install it into the active
|
||||
venv with `pip`:
|
||||
|
||||
```bash
|
||||
uv pip install --python /app/.venv/bin/python <package>
|
||||
pip install <package>
|
||||
```
|
||||
|
||||
Plain `pip install <package>` also works because the venv is active. Install
|
||||
before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
Install before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
|
||||
@@ -180,14 +180,6 @@ def viewer_email_event(step: str, purpose: str | None = None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def viewer_feedback_submitted() -> None:
|
||||
_send("viewer_feedback_submitted", {**base_props()})
|
||||
|
||||
|
||||
def viewer_agent_steered() -> None:
|
||||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
|
||||
@@ -207,26 +207,6 @@ def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def feedback_submit(email: str, message: str) -> None:
|
||||
"""Relay a feedback message + email to Strix. No verification is required;
|
||||
the email is taken as given. Raises RelayError on failure."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/feedback",
|
||||
{"email": email, "message": message},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
code = data.get("error")
|
||||
if code in ("invalid_email", "invalid_message"):
|
||||
raise RelayError(str(code))
|
||||
raise RelayError("invalid_message")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
@@ -261,7 +241,6 @@ def report_send(
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
|
||||
Generated
+63
-19
@@ -16,7 +16,6 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
@@ -63,7 +62,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -921,6 +919,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -935,6 +936,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -949,6 +953,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -963,6 +970,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -977,6 +987,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -991,6 +1004,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1005,6 +1021,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1019,6 +1038,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1033,6 +1055,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1047,6 +1072,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1061,6 +1089,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1075,6 +1106,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1089,6 +1123,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1312,6 +1349,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1329,6 +1369,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1346,6 +1389,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1363,6 +1409,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1605,7 +1654,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1616,7 +1664,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1739,7 +1786,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001803",
|
||||
@@ -1920,7 +1966,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -2498,6 +2543,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2519,6 +2567,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2540,6 +2591,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2561,6 +2615,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3571,7 +3628,6 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3623,7 +3679,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3633,7 +3688,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3641,15 +3695,6 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -4109,7 +4154,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Waypoints,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Wrench,
|
||||
FileCheck2,
|
||||
CalendarClock,
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
@@ -41,10 +45,11 @@ import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
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" | "email" | "feedback";
|
||||
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.";
|
||||
@@ -52,12 +57,25 @@ const TRUST_BANNER =
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
|
||||
// tiles), plus the recommendations pairing.
|
||||
const RECOMMENDATION_CTAS: ProItem[] = [
|
||||
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
|
||||
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
|
||||
];
|
||||
const COVERAGE_CTAS: ProItem[] = [
|
||||
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
|
||||
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
|
||||
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
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 [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
@@ -231,6 +249,12 @@ export default function App() {
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const selectFeature = useCallback((slug: string) => {
|
||||
trackCta(slug, "sidebar_nav");
|
||||
setActiveFeature(slug);
|
||||
userSetView("feature");
|
||||
}, [userSetView]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
@@ -242,13 +266,11 @@ export default function App() {
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
activeFeature={activeFeature}
|
||||
onSelectFeature={selectFeature}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
@@ -263,7 +285,7 @@ export default function App() {
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
@@ -275,6 +297,7 @@ export default function App() {
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
<span className="text-xs text-[#666]">Local results</span>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
@@ -299,20 +322,14 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
@@ -325,11 +342,8 @@ export default function App() {
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -355,7 +369,7 @@ 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={() => userSetView("overview")}>
|
||||
Pentest Overview
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
@@ -398,7 +412,6 @@ export default function App() {
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
@@ -425,37 +438,33 @@ function RunSwitcher({
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
<History className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="max-w-[160px] truncate">{current}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</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-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -499,7 +508,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest 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 && (
|
||||
@@ -538,7 +547,7 @@ function FindingsList({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -564,7 +573,7 @@ function FindingsList({
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
@@ -627,7 +636,7 @@ function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
Email report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -664,32 +673,26 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
{finished && <EmailReportCta onOpenEmail={onOpenEmail} />}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -698,6 +701,22 @@ function OverviewTab({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Continuous coverage for your org (restyled upsell tiles). */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -727,7 +746,8 @@ function TabButton({
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
// cloud app); no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
@@ -738,7 +758,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
@@ -764,12 +784,12 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
@@ -777,13 +797,14 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
{selectedAgent && (
|
||||
<AgentDetailModal
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function EmailReportView({
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<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"
|
||||
@@ -192,7 +192,7 @@ export default function EmailReportView({
|
||||
<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" : "Export report to PDF"}
|
||||
{verifyOnly ? "Verify your email" : "Email report"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -225,13 +225,16 @@ export default function EmailReportView({
|
||||
<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]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
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]">
|
||||
Only you hold the password; Strix can't read it.
|
||||
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>
|
||||
@@ -239,7 +242,7 @@ export default function EmailReportView({
|
||||
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"
|
||||
>
|
||||
Export report
|
||||
{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>
|
||||
@@ -266,6 +269,7 @@ export default function EmailReportView({
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, 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={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
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={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("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" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 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"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export default function PastRunsView({
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, 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
|
||||
* sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
|
||||
* Built once here and reused by the sidebar Platform section, the top upsell
|
||||
* row, and the inline CTAs in the tabs.
|
||||
*/
|
||||
|
||||
/** 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)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
@@ -37,10 +58,100 @@ export function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline CTA button that links out to sign-up in a new tab, with a
|
||||
* hover tooltip one-liner. Used in per-surface rows where a full card is too
|
||||
* heavy.
|
||||
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
|
||||
* Used in the top upsell row and inline CTA grids.
|
||||
*/
|
||||
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, item.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug, surface)}
|
||||
title={item.desc}
|
||||
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProTag />
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white">{item.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">{item.desc}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
feature,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
feature: ProFeature;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const Icon = feature.icon;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${feature.title} (${feature.tier})`}
|
||||
className={`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 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" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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" 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">{feature.title}</span>
|
||||
<ProTag label={feature.tier} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Pro CTA button (compact). Used in the finding detail and per-surface
|
||||
* rows where a full card is too heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
@@ -66,6 +177,7 @@ export function ProInlineCta({
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<ProTag className="ml-0.5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -72,13 +72,7 @@ export function RunDetails({
|
||||
const diffMode = str(diff.mode);
|
||||
const diffBase = str(raw.diff_base);
|
||||
const nonInteractive = raw.non_interactive === true;
|
||||
const localSources = arr(raw.local_sources)
|
||||
.map((x) => {
|
||||
if (typeof x === "string") return x;
|
||||
const o = rec(x);
|
||||
return str(o.source_path) ?? str(o.target_path) ?? "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
|
||||
const status = cap(str(raw.status));
|
||||
|
||||
let scope = scopeMode ?? "auto";
|
||||
@@ -152,7 +146,7 @@ export function RunDetails({
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
|
||||
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Users,
|
||||
FileText,
|
||||
Bug,
|
||||
Waypoints,
|
||||
History,
|
||||
Mail,
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
ShieldCheck,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import { LuGitPullRequestArrow } from "react-icons/lu";
|
||||
import { VscExtensions } from "react-icons/vsc";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { UpgradeModal } from "@/components/UpgradeModal";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail: a black rail with a right hairline border, an
|
||||
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
|
||||
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
|
||||
* separator, and a user footer. Drag the right edge to resize; drag past the
|
||||
* collapse threshold to hide it, then click the left pull-zone to bring it back.
|
||||
* 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.
|
||||
*
|
||||
* Can collapse to a narrow icon-only rail; the collapsed state persists in
|
||||
* localStorage and each icon row keeps a `title` tooltip so the labels stay
|
||||
* discoverable.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MAX_WIDTH = 400;
|
||||
const COLLAPSE_THRESHOLD = 140;
|
||||
|
||||
const WIDTH_KEY = "strix_viewer_sidebar_width";
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
activeFeature: string | null;
|
||||
onSelectFeature: (slug: string) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
@@ -46,19 +46,11 @@ interface SidebarProps {
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function readInt(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
activeFeature,
|
||||
onSelectFeature,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
@@ -69,367 +61,229 @@ export default function Sidebar({
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
|
||||
} catch {
|
||||
/* localStorage may be unavailable; default to expanded */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCollapsed = useCallback((c: boolean) => {
|
||||
setCollapsed(c);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
persistCollapsed(false);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
}, [persistCollapsed, persistWidth]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Global drag handlers for the resize handle. Dragging below the collapse
|
||||
// threshold hides the rail entirely.
|
||||
useEffect(() => {
|
||||
if (!isResizing || collapsed) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setWidth(newWidth);
|
||||
} else if (newWidth > MAX_WIDTH) {
|
||||
setWidth(MAX_WIDTH);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const finalWidth = e.clientX;
|
||||
if (finalWidth < COLLAPSE_THRESHOLD) {
|
||||
persistCollapsed(true);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
} else {
|
||||
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
|
||||
}
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
|
||||
|
||||
// Close the user menu when clicking outside it.
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
|
||||
onClick={expandSidebar}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
|
||||
!isResizing && "transition-[width] duration-200 ease-out"
|
||||
)}
|
||||
style={{ width: collapsed ? 0 : width }}
|
||||
>
|
||||
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
|
||||
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
|
||||
<div className="flex flex-row py-1 px-2">
|
||||
<div className="flex h-10 w-full flex-row items-center">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
|
||||
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
aria-label="Open Strix Cloud"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
|
||||
</a>
|
||||
</div>
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r border-[#222] lg:block ${collapsed ? "w-14" : "w-72"}`}
|
||||
>
|
||||
<div className="sticky top-0 flex h-screen flex-col overflow-y-auto px-3 py-4">
|
||||
{/* Header: wordmark + Explore full platform + signed-in chip */}
|
||||
<div className="px-1.5">
|
||||
<div className={`flex items-center ${collapsed ? "flex-col gap-2" : "justify-between"}`}>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="h-8 w-10 object-cover" />
|
||||
{!collapsed && (
|
||||
<span className="text-base font-medium tracking-tight text-white">Strix</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="flex-shrink-0 cursor-pointer rounded-md p-1.5 text-[#666] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label="Issues"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label="Agents"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label="Past runs"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<IoChatbubblesOutline className="h-4 w-4" />}
|
||||
label="Feedback & support"
|
||||
active={view === "feedback"}
|
||||
onClick={() => onSelectView("feedback")}
|
||||
/>
|
||||
|
||||
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
|
||||
|
||||
<NavItem
|
||||
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<VscExtensions className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User footer — verified-email footer. */}
|
||||
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
|
||||
<div className="relative p-2">
|
||||
{verified && email ? (
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("sidebar_start_free", "sidebar")}
|
||||
title="Explore full platform"
|
||||
className={`mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white font-semibold text-black transition-opacity hover:opacity-90 ${
|
||||
collapsed ? "px-0 py-2" : "px-3 py-2 text-sm"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && "Explore full platform"}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
{verified && email && (
|
||||
collapsed ? (
|
||||
<div
|
||||
className="mt-2.5 flex items-center justify-center rounded-lg py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
title={`Linked email: ${email}`}
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">
|
||||
{email[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
|
||||
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
|
||||
</span>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUserMenu && verified && email && (
|
||||
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
|
||||
<div className="border-b border-[#333] px-3 py-2">
|
||||
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
|
||||
<p className="truncate text-[11px] text-[#666]">{email}</p>
|
||||
) : (
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-2 rounded-lg px-2.5 py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] text-[#666]">Linked email</p>
|
||||
<p className="truncate text-xs text-[#aaa]" title={email}>{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onForget();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
|
||||
onClick={onForget}
|
||||
title="Forget this email on this machine"
|
||||
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
|
||||
aria-label="Forget"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Forget this email
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
|
||||
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay during resize to prevent text selection. */}
|
||||
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
|
||||
{/* 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")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Bug}
|
||||
label="Issues"
|
||||
desc="Findings from this run"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={Waypoints}
|
||||
label="Agents"
|
||||
desc="What each agent did"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={History}
|
||||
label="Past runs"
|
||||
desc="Every run on this machine"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{/* Emailing a report only makes sense once the run is complete; a
|
||||
live scan would send a partial report, so hide it until finished. */}
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeFeature !== null}
|
||||
description={upgradeFeature ?? ""}
|
||||
source="sidebar"
|
||||
onClose={() => setUpgradeFeature(null)}
|
||||
/>
|
||||
</>
|
||||
{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)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
desc,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
desc: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={count != null ? `${label} (${count})` : label}
|
||||
className={`flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 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" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex h-9 w-full origin-left flex-row items-center rounded-md 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
|
||||
? "bg-[rgba(255,255,255,0.12)] text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
|
||||
)}
|
||||
? "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}
|
||||
>
|
||||
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
|
||||
{count != null && (
|
||||
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
|
||||
{count}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview icon: a dashboard grid glyph (16x16 viewBox).
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Shield,
|
||||
Zap,
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
* description of the feature plus what Strix Cloud includes. The local viewer
|
||||
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
|
||||
*/
|
||||
|
||||
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
|
||||
{ icon: GitPullRequest, label: "PR security reviews" },
|
||||
{ icon: Shield, label: "Attack surface monitoring" },
|
||||
{ icon: Zap, label: "Real-time threat intelligence" },
|
||||
{ icon: CalendarClock, label: "Scheduled pentesting" },
|
||||
{ icon: WandSparkles, label: "One-click autofix" },
|
||||
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
|
||||
];
|
||||
|
||||
export function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
description,
|
||||
source = "sidebar",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** A short sentence describing what the clicked feature does. */
|
||||
description: string;
|
||||
source?: string;
|
||||
}) {
|
||||
// Keep the dialog mounted through its exit animation: `render` controls
|
||||
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
|
||||
// close we flip to "closed", let the 200ms animation play, then unmount --
|
||||
// the same lifecycle Radix gives shadcn's Dialog.
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade your plan"
|
||||
>
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-[#888]">
|
||||
{CLOUD_HIGHLIGHTS.map((f) => (
|
||||
<li key={f.label} className="flex items-center gap-2">
|
||||
<f.icon className="h-3.5 w-3.5 text-[#555]" />
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_try_free", source)}
|
||||
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
Open Strix Cloud
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_view_plans", source)}
|
||||
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
@@ -18,27 +18,19 @@ const STATUS_DOT: Record<string, string> = {
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
/**
|
||||
* Overlay modal showing a single agent's full transcript. A centered
|
||||
* ``max-w-6xl`` / ``60vh`` panel that animates in and out via the shared
|
||||
* ``agent-modal`` data-state keyframes (fade), with a pinned header
|
||||
* (status dot + agent name),
|
||||
* the transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom. Closes on backdrop click, the X
|
||||
* button, or Escape.
|
||||
*
|
||||
* Driven by an ``open`` prop (rather than conditional mounting) so the exit
|
||||
* animation can play before unmount; the last agent is retained through the
|
||||
* close so content doesn't blank out mid-animation.
|
||||
* Overlay modal showing a single agent's full transcript. Matches the cloud
|
||||
* app: a fixed-size panel with a pinned header (status dot + agent name), the
|
||||
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom (so a live run trails). Closes on
|
||||
* backdrop click, the X button, or Escape.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
open,
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
agent: TranscriptAgent | null;
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
@@ -46,42 +38,6 @@ export function AgentDetailModal({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
// Keep the modal mounted through its exit animation (see UpgradeModal).
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
// Defer the (heavy) transcript one frame so the shell + fade paint instantly
|
||||
// instead of waiting on the full event list to render.
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
|
||||
// Retain the last non-null agent so the panel keeps rendering its content
|
||||
// during the close animation, after the parent has cleared the selection.
|
||||
const lastAgentRef = useRef<TranscriptAgent | null>(agent);
|
||||
useEffect(() => {
|
||||
if (agent) lastAgentRef.current = agent;
|
||||
}, [agent]);
|
||||
const shownAgent = agent ?? lastAgentRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 140);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
// Mount the transcript a frame after the shell is on screen.
|
||||
useEffect(() => {
|
||||
if (!render) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => setContentReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [render]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -98,7 +54,6 @@ export function AgentDetailModal({
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
@@ -109,30 +64,27 @@ export function AgentDetailModal({
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render || !shownAgent) return null;
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${shownAgent.name}`}
|
||||
aria-label={`Agent ${agent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[shownAgent.status] ?? "bg-[#888]"}`}
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{shownAgent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{shownAgent.id}</span>
|
||||
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,16 +97,14 @@ export function AgentDetailModal({
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
|
||||
{contentReady && (
|
||||
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
|
||||
)}
|
||||
<AgentTranscript agent={agent} events={events} showHeader={false} />
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[shownAgent]}
|
||||
fixedAgentId={shownAgent.id}
|
||||
agents={[agent]}
|
||||
fixedAgentId={agent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,7 @@ export function ScanPromptComposer({
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running pentest…"
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
// `skills` may arrive as an array of names or a comma-separated string
|
||||
// depending on the tool call, so normalize both to a clean list.
|
||||
const raw = args.skills;
|
||||
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((skill) => String(skill).trim())
|
||||
const requestedRaw = (args.skills as string) ?? "";
|
||||
const requestedSkills = requestedRaw
|
||||
.split(",")
|
||||
.map((skill) => skill.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge, GitPullRequest } from "lucide-react";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
@@ -29,15 +29,6 @@ const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; labe
|
||||
|
||||
type BottomTab = "fix" | "reproduction";
|
||||
|
||||
// Team-workflow actions shown top-right of the finding header; each links out
|
||||
// to sign-up. `requiresCode` actions only appear when the finding has concrete
|
||||
// code locations to act on -- an autofix PR makes no sense for a black-box
|
||||
// finding with no code to change.
|
||||
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
|
||||
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
|
||||
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
|
||||
];
|
||||
|
||||
interface VulnerabilityDetailProps {
|
||||
vulnerability: Vulnerability;
|
||||
}
|
||||
@@ -63,9 +54,8 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header: title/badges on the left, workflow actions top-right. */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{vulnerability.display_number && (
|
||||
<span className="text-xs font-mono text-[#555] block mb-1">
|
||||
@@ -98,26 +88,6 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
|
||||
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
|
||||
const Icon = cta.icon;
|
||||
return (
|
||||
<a
|
||||
key={cta.slug}
|
||||
href={ctaUrl(SIGNUP_URL, cta.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(cta.slug, "finding_detail")}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{cta.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
@@ -260,6 +230,36 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team-workflow CTAs (Pro). Highest-intent surface: act on this finding. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Ship the fix with your team</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">
|
||||
Take this finding into your team's workflow.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Auto-fix & open a PR"
|
||||
desc="Fix it for you and open a PR, retested."
|
||||
slug="autofix"
|
||||
icon={Wrench}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Sync to Jira / Linear"
|
||||
desc="Two-way sync findings to Jira, Linear, and Slack."
|
||||
slug="integrations"
|
||||
icon={GitMerge}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Catch this in PR reviews"
|
||||
desc="Pentest every pull request your team opens."
|
||||
slug="pr_reviews"
|
||||
icon={GitPullRequest}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
} from "@/lib/local-run-parser";
|
||||
|
||||
/**
|
||||
* Data seam for the local viewer: fetches against the local Python server's
|
||||
* JSON endpoints (same origin, relative URLs), producing the in-memory
|
||||
* `LoadedRun` shape the UI renders plus a `finished` flag driving live polling.
|
||||
* Data seam for the local viewer. Replaces strix-app's browser file-picker
|
||||
* (`loadFromTexts`) with fetches against the local Python server's JSON
|
||||
* endpoints (same origin, relative URLs). Produces the same in-memory
|
||||
* `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling.
|
||||
*
|
||||
* The server serves a live in-progress run and a finished one identically; the
|
||||
* only signal is `run.finished`.
|
||||
@@ -200,21 +201,6 @@ export async function steerAgent(agentId: string, message: string): Promise<Stee
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export type SubmitFeedbackResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* POST /api/feedback. Sends a feedback message plus a work email (no
|
||||
* verification) to the local server, which relays it to Strix.
|
||||
*/
|
||||
export async function submitFeedback(
|
||||
message: string,
|
||||
email: string
|
||||
): Promise<SubmitFeedbackResult> {
|
||||
const { ok, data } = await postJson("/api/feedback", { message, email });
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
|
||||
return { verified: obj?.verified === true, email: obj?.email ?? null };
|
||||
|
||||
@@ -19,161 +19,7 @@ body {
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Thin sidebar scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Motion vocabulary --------------------- */
|
||||
|
||||
/* Page transition: replayed on every view change via a keyed wrapper. */
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Plain fade. */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 350ms ease-out;
|
||||
}
|
||||
|
||||
/* Staggered card entrance for lists/grids (first four cascade). */
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-card-in {
|
||||
opacity: 0;
|
||||
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-card-in:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.animate-card-in:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.animate-card-in:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.animate-card-in:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for progress indicators. */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
|
||||
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
|
||||
data-state attribute rather than a transition, so the enter always plays. */
|
||||
@keyframes dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
.dialog-overlay[data-state="open"] {
|
||||
animation: dialog-overlay-in 200ms ease;
|
||||
}
|
||||
.dialog-overlay[data-state="closed"] {
|
||||
animation: dialog-overlay-out 200ms ease forwards;
|
||||
}
|
||||
.dialog-panel[data-state="open"] {
|
||||
animation: dialog-panel-in 200ms ease;
|
||||
}
|
||||
.dialog-panel[data-state="closed"] {
|
||||
animation: dialog-panel-out 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
|
||||
transcript, and animating a transform on that much DOM janks; fading the
|
||||
overlay (the panel inherits its opacity) stays cheap and snappy. */
|
||||
.agent-modal[data-state="open"] {
|
||||
animation: dialog-overlay-in 140ms ease;
|
||||
}
|
||||
.agent-modal[data-state="closed"] {
|
||||
animation: dialog-overlay-out 140ms ease forwards;
|
||||
}
|
||||
|
||||
/* Tab content transition. */
|
||||
/* Tab content transition (lifted from strix-app globals.css) */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -186,11 +32,12 @@ body {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling */
|
||||
/* Markdown prose styling (lifted from strix-app globals.css) */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -57,5 +57,5 @@ export function parseTarget(target: string): ParsedTarget {
|
||||
*/
|
||||
export function runTitle(target: string | null, fallback: string): string {
|
||||
if (target) return parseTarget(target).display.replace(/\/$/, "");
|
||||
return fallback || "Untitled pentest";
|
||||
return fallback || "Untitled scan";
|
||||
}
|
||||
|
||||
@@ -174,8 +174,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._handle_forget()
|
||||
elif path == "/api/report/send":
|
||||
self._handle_report_send()
|
||||
elif path == "/api/feedback":
|
||||
self._handle_feedback()
|
||||
elif path == "/api/agents/steer":
|
||||
self._handle_steer()
|
||||
else:
|
||||
@@ -220,10 +218,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
|
||||
purpose = body.get("purpose")
|
||||
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
|
||||
elif event == "agent_steered":
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_agent_steered()
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
|
||||
@@ -385,37 +379,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
{"ok": True, "password": password, "filename": filename},
|
||||
)
|
||||
|
||||
# Cap on a feedback message so a runaway client cannot flood the relay.
|
||||
_FEEDBACK_MESSAGE_MAX = 5000
|
||||
|
||||
def _handle_feedback(self) -> None:
|
||||
# Requires this process's session capability, like the other POSTs,
|
||||
# so an exposed --host port can't be used to spam the relay.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
body = self._read_body()
|
||||
email = str(body.get("email") or "").strip()
|
||||
message = str(body.get("message") or "").strip()
|
||||
if not email:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
|
||||
return
|
||||
if not message:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
|
||||
return
|
||||
message = message[: self._FEEDBACK_MESSAGE_MAX]
|
||||
try:
|
||||
auth.feedback_submit(email, message)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# Server-authoritative: fire only after a successful relay (respects
|
||||
# the telemetry opt-out; no message/email content is sent).
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_feedback_submitted()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
# Cap on a steering message so a runaway client cannot flood the agent.
|
||||
_STEER_MESSAGE_MAX = 4000
|
||||
|
||||
@@ -450,7 +413,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
status_by_code = {
|
||||
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"invalid_email": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_message": HTTPStatus.BAD_REQUEST,
|
||||
"work_email_required": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_code": HTTPStatus.FORBIDDEN,
|
||||
"reverify": HTTPStatus.UNAUTHORIZED,
|
||||
|
||||
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
File diff suppressed because one or more lines are too long
@@ -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-BNKUksp9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BdiSGmzb.css">
|
||||
<script type="module" crossorigin src="./assets/index-BU_tk5L-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-C0NveaV7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-58
@@ -191,62 +191,6 @@ def test_server_event_endpoint_forwards_email_funnel(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_agent_steered(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "steerevt", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr("strix.telemetry.posthog.viewer_agent_steered", lambda: seen.append(True))
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event",
|
||||
data=json.dumps({"event": "agent_steered"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == [True]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_feedback_records_telemetry_on_success(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "fbtel", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
sent: list[bool] = []
|
||||
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None)
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
|
||||
)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
# A successful, session-holding submission relays and records telemetry.
|
||||
status, _ = _post(
|
||||
url, "/api/feedback", {"email": "a@b.com", "message": "hi"}, cookie=cookie
|
||||
)
|
||||
assert status == 200
|
||||
assert sent == [True]
|
||||
|
||||
# A cookie-less caller is rejected and records nothing.
|
||||
sent.clear()
|
||||
status, _ = _post(url, "/api/feedback", {"email": "a@b.com", "message": "hi"})
|
||||
assert status == 403
|
||||
assert sent == []
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _post(
|
||||
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
|
||||
) -> tuple[int, bytes]:
|
||||
@@ -451,7 +395,9 @@ def test_report_send_requires_session_cookie(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_report_send_rejects_live_run(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# A running scan would only produce a partial report, so the endpoint must
|
||||
# fail closed even for a verified, session-holding caller.
|
||||
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
@@ -460,7 +406,9 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, _ = _post(url, "/api/report/send", {}, cookie=_session_cookie(url, token))
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {}, cookie=_session_cookie(url, token)
|
||||
)
|
||||
assert status == 409
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user