Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions __tests__/components/reach-developers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ describe("ReachDevelopers", () => {

// Dropdown should now be visible
expect(screen.getByText("Request a Feature")).toBeInTheDocument();
expect(screen.getByText("Report a Bug")).toBeInTheDocument();
expect(screen.getByText("General Inquiry")).toBeInTheDocument();
expect(screen.getByText("Report an Issue")).toBeInTheDocument();
expect(screen.getByText("Ask a Question")).toBeInTheDocument();
});

it("contains correct mailto links with subjects", async () => {
Expand All @@ -38,37 +38,33 @@ describe("ReachDevelopers", () => {
const featureLink = screen.getByText("Request a Feature").closest("a");
expect(featureLink).toHaveAttribute(
"href",
expect.stringContaining("mailto:failproofai@exosphere.host")
expect.stringContaining("github.com/exospherehost/failproofai")
);
expect(featureLink).toHaveAttribute(
"href",
expect.stringContaining("subject=")
expect.stringContaining("labels=enhancement")
);

const bugLink = screen.getByText("Report a Bug").closest("a");
const bugLink = screen.getByText("Report an Issue").closest("a");
expect(bugLink).toHaveAttribute(
"href",
expect.stringContaining("mailto:failproofai@exosphere.host")
expect.stringContaining("github.com/exospherehost/failproofai")
);
});

it("click outside closes dropdown", async () => {
const user = userEvent.setup();
render(
<div>
<div data-testid="outside">Outside</div>
<ReachDevelopers />
</div>
);
const { container } = render(<ReachDevelopers />);

// Open dropdown
const btn = screen.getAllByRole("button")[0];
await user.click(btn);
expect(screen.getByText("Request a Feature")).toBeInTheDocument();

// Click outside
await user.click(screen.getByTestId("outside"));
expect(screen.queryByText("Request a Feature")).not.toBeInTheDocument();
// Click the backdrop overlay (the fixed inset-0 div rendered when open)
const backdrop = container.querySelector('[aria-hidden="true"]') as HTMLElement;
await user.click(backdrop);
expect(screen.queryByText("Report an Issue")).not.toBeInTheDocument();
});

// ARIA attribute tests
Expand Down Expand Up @@ -117,6 +113,6 @@ describe("ReachDevelopers", () => {
expect(screen.getByText("Request a Feature")).toBeInTheDocument();

await user.keyboard("{Escape}");
expect(screen.queryByText("Request a Feature")).not.toBeInTheDocument();
expect(screen.queryByText("Report an Issue")).not.toBeInTheDocument();
});
});
Empty file modified bin/failproofai.mjs
100644 → 100755
Empty file.
71 changes: 37 additions & 34 deletions components/reach-developers.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,59 @@
/** Dropdown menu for users to reach the development team via email. */
/** Dropdown menu for users to reach the development team via GitHub. */
"use client";

import React, { useState, useRef, useEffect, useCallback } from "react";
import { Mail, Lightbulb, Bug, MessageSquare, ChevronDown } from "lucide-react";
import React, { useState, useCallback } from "react";
import { GitBranch, Lightbulb, Bug, MessageSquare, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";

const GITHUB_REPO = "https://github.com/exospherehost/failproofai";
const CONTACT_EMAIL = "failproofai@exosphere.host";

const options = [
{
label: "Request a Feature",
icon: Lightbulb,
subject: "Feature Request: ",
href: `${GITHUB_REPO}/issues/new?labels=enhancement&title=Feature+Request%3A+`,
},
{
label: "Report a Bug",
label: "Report an Issue",
icon: Bug,
subject: "Bug Report: ",
href: `${GITHUB_REPO}/issues/new?labels=bug&title=Bug+Report%3A+`,
},
{
label: "General Inquiry",
label: "Ask a Question",
icon: MessageSquare,
subject: "General Inquiry: ",
href: `${GITHUB_REPO}/discussions/new?category=q-a`,
},
] as const;

export const ReachDevelopers: React.FC = () => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const close = useCallback(() => setOpen(false), []);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
}
}, [open]);
if (e.key === "Escape") setOpen(false);
}, []);

return (
<div ref={ref} className="relative" onKeyDown={handleKeyDown}>
<div className="relative" onKeyDown={handleKeyDown}>
{open && (
<div
className="fixed inset-0 z-40"
onClick={close}
aria-hidden="true"
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-haspopup="true"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
className="relative z-50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
>
<Mail className="h-4 w-4" />
<GitBranch className="h-4 w-4" />
<span className="hidden sm:inline text-xs">Reach Us</span>
<ChevronDown
className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`}
Expand All @@ -71,26 +69,31 @@ export const ReachDevelopers: React.FC = () => {
</p>
</div>
<div className="py-1">
{options.map(({ label, icon: Icon, subject }) => (
{options.map(({ label, icon: Icon, href }) => (
<a
key={label}
href={`mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}`}
href={href}
target="_blank"
rel="noopener noreferrer"
role="menuitem"
className="flex items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setOpen(false)}
onClick={close}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
{label}
</a>
))}
</div>
<div className="px-3 py-2 border-t border-border">
<a
href={`mailto:${CONTACT_EMAIL}`}
className="text-[0.65rem] text-primary hover:text-primary/80 transition-colors"
>
{CONTACT_EMAIL}
</a>
<p className="text-[0.65rem] text-muted-foreground">
or email{" "}
<a
href={`mailto:${CONTACT_EMAIL}`}
className="text-primary hover:text-primary/80 transition-colors"
>
{CONTACT_EMAIL}
</a>
</p>
</div>
</div>
)}
Expand Down
23 changes: 23 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@ failproofai --start

Starts the web dashboard at `http://localhost:8020`. Use `--start` for production mode (Next.js standalone). The default bare invocation uses development mode with auto-reload.

**Options (dev mode only):**

| Flag | Description |
|------|-------------|
| `--port <number>` | Port to listen on (default: `8020`) |
| `--projects-path <path>` | Override the Claude projects path |
| `--allowed-origins <origins>` | Comma-separated list of hosts/IPs allowed to access dev resources (e.g. HMR websocket). Required when accessing the dev server from a hostname other than `localhost`. |

**Example — allow a custom hostname in dev:**

```bash
npm run dev -- --allowed-origins dashboard.example.com
# or multiple:
npm run dev -- --allowed-origins dashboard.example.com,192.168.1.5
```

You can also set the env var directly instead of using the flag:

```bash
FAILPROOFAI_ALLOWED_DEV_ORIGINS=dashboard.example.com npm run dev
```

---

## Hook handler (internal)
Expand Down Expand Up @@ -145,4 +167,5 @@ Prints the installed version number.
| `FAILPROOFAI_TELEMETRY_DISABLED=1` | Disable anonymous usage telemetry |
| `FAILPROOFAI_LOG_LEVEL=info\|warn\|error` | Server log level (default: `warn`) |
| `FAILPROOFAI_DISABLE_PAGES=policies,projects` | Comma-separated list of dashboard pages to disable |
| `FAILPROOFAI_ALLOWED_DEV_ORIGINS` | Comma-separated list of hosts/IPs allowed to access Next.js dev resources (HMR). Dev mode only. Equivalent to `--allowed-origins`. |
| `CLAUDE_PROJECTS_PATH` | Override the path where Claude Code project folders are found |
30 changes: 30 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,33 @@ By default, the dashboard reads from the standard Claude Code projects directory
```bash
CLAUDE_PROJECTS_PATH=/custom/path/to/projects failproofai
```

---

## Accessing from a non-localhost host

When running the dashboard in **dev mode** (`npm run dev`) and accessing it from a hostname other than `localhost` — for example, a custom domain, a remote IP, or a tunneled URL — you may see a warning like:

```
⚠ Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr from "dashboard.example.com".
```

This is Next.js blocking cross-origin access to its HMR (hot module reload) websocket, which is a dev-only feature. To allow your host, use the `--allowed-origins` flag:

```bash
npm run dev -- --allowed-origins dashboard.example.com
```

For multiple hosts or IPs, pass a comma-separated list:

```bash
npm run dev -- --allowed-origins dashboard.example.com,192.168.1.5
```

You can also set the `FAILPROOFAI_ALLOWED_DEV_ORIGINS` environment variable instead:

```bash
FAILPROOFAI_ALLOWED_DEV_ORIGINS=dashboard.example.com npm run dev
```

> **Note:** This only applies to dev mode. When running `failproofai` (production mode), there is no HMR websocket and no cross-origin dev resource issue.
5 changes: 5 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { NextConfig } from "next";

const allowedDevOrigins = process.env.FAILPROOFAI_ALLOWED_DEV_ORIGINS
? process.env.FAILPROOFAI_ALLOWED_DEV_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;

const nextConfig: NextConfig = {
...(allowedDevOrigins ? { allowedDevOrigins } : {}),
output: "standalone",
productionBrowserSourceMaps: false,
turbopack: {
Expand Down
3 changes: 2 additions & 1 deletion scripts/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { parseScriptArgs } from "./parse-script-args";
import { version } from "../package.json";

export function launch(mode: "dev" | "start"): void {
const { claudeProjectsPath: parsedPath, loggingLevel, disableTelemetry, remainingArgs } = parseScriptArgs(process.argv.slice(2));
const { claudeProjectsPath: parsedPath, loggingLevel, disableTelemetry, allowedDevOrigins, remainingArgs } = parseScriptArgs(process.argv.slice(2));

console.log(`
______ _ __ ____ ___ ____
Expand Down Expand Up @@ -53,6 +53,7 @@ export function launch(mode: "dev" | "start"): void {
CLAUDE_PROJECTS_PATH: claudeProjectsPath,
...(loggingLevel ? { FAILPROOFAI_LOG_LEVEL: loggingLevel } : {}),
...(disableTelemetry ? { FAILPROOFAI_TELEMETRY_DISABLED: "1" } : {}),
...(allowedDevOrigins ? { FAILPROOFAI_ALLOWED_DEV_ORIGINS: allowedDevOrigins.join(",") } : {}),
},
});

Expand Down
12 changes: 11 additions & 1 deletion scripts/parse-script-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ParsedScriptArgs {
claudeProjectsPath: string | undefined;
loggingLevel: string | undefined;
disableTelemetry: boolean;
allowedDevOrigins: string[] | undefined;
remainingArgs: string[];
}

Expand All @@ -32,6 +33,7 @@ export function parseScriptArgs(argv: string[]): ParsedScriptArgs {
let claudeProjectsPath: string | undefined;
let loggingLevel: string | undefined;
let disableTelemetry = false;
let allowedDevOrigins: string[] | undefined;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
Expand Down Expand Up @@ -71,7 +73,15 @@ export function parseScriptArgs(argv: string[]): ParsedScriptArgs {
i--;
continue;
}

if (flag === "--allowed-origins") {
const { value, spliceCount } = parseStringFlag(flag, "a comma-separated list of origins", inlineValue, args, i);
allowedDevOrigins = value.split(",").map(s => s.trim()).filter(Boolean);
args.splice(i, spliceCount);
i--;
continue;
}
}

return { claudeProjectsPath, loggingLevel, disableTelemetry, remainingArgs: args };
return { claudeProjectsPath, loggingLevel, disableTelemetry, allowedDevOrigins, remainingArgs: args };
}