WitNote / dashboard /src /pages /SettingsPage.tsx
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
import { useEffect, useMemo, useState } from "react";
import { useAppStore } from "../stores/useAppStore";
import { Button, Card } from "../components/atoms";
import * as api from "../services/api";
import type {
BackendConfig,
BackendConfigState,
BackendIDPIConfig,
BackendSecurityConfig,
LocalDashboardSettings,
} from "../types";
type SectionId =
| "dashboard"
| "defaults"
| "orchestration"
| "security"
| "security-idpi"
| "profiles"
| "network"
| "browser"
| "timeouts";
const sections: Array<{
id: SectionId;
label: string;
description: string;
}> = [
{
id: "dashboard",
label: "Dashboard",
description: "Local monitoring and screencast preferences.",
},
{
id: "defaults",
label: "Instance Defaults",
description: "How new managed browser instances launch.",
},
{
id: "orchestration",
label: "Orchestration",
description: "Routing strategy, port range, and allocation policy.",
},
{
id: "security",
label: "Security",
description: "Sensitive endpoint gates and access controls.",
},
{
id: "security-idpi",
label: "Security IDPI",
description: "Indirect prompt injection website and content defenses.",
},
{
id: "profiles",
label: "Profiles",
description: "Shared profile storage and default profile behavior.",
},
{
id: "network",
label: "Network & Attach",
description: "Server binding, auth, and attach policy.",
},
{
id: "browser",
label: "Browser Runtime",
description: "Chrome binary, version, flags, and extensions.",
},
{
id: "timeouts",
label: "Timeouts",
description: "Action, navigation, shutdown, and wait timing.",
},
];
const fieldClass =
"w-full rounded-sm border border-border-subtle bg-[rgb(var(--brand-surface-code-rgb)/0.72)] px-3 py-2 text-sm text-text-primary placeholder:text-text-muted transition-all duration-150 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
const selectClass =
"rounded-sm border border-border-subtle bg-[rgb(var(--brand-surface-code-rgb)/0.72)] px-3 py-2 text-sm text-text-primary transition-all duration-150 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
type SecurityEndpointKey = Exclude<keyof BackendSecurityConfig, "attach">;
type IDPIToggleKey = Exclude<
keyof BackendIDPIConfig,
"allowedDomains" | "customPatterns"
>;
const securityEndpointRows = [
["allowEvaluate", "Allow evaluate"],
["allowMacro", "Allow macro"],
["allowScreencast", "Allow screencast"],
["allowDownload", "Allow download"],
["allowUpload", "Allow upload"],
] as const satisfies ReadonlyArray<readonly [SecurityEndpointKey, string]>;
const idpiToggleRows = [
["enabled", "Enable IDPI", "Turn on indirect prompt injection defenses."],
[
"strictMode",
"Strict mode",
"Block disallowed domains and suspicious content instead of only warning.",
],
[
"scanContent",
"Scan content",
"Inspect extracted text and snapshots for prompt-injection patterns.",
],
[
"wrapContent",
"Wrap content",
"Mark returned page text as untrusted content for downstream consumers.",
],
] as const satisfies ReadonlyArray<readonly [IDPIToggleKey, string, string]>;
function csvToList(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function listToCsv(value: string[]): string {
return value.join(", ");
}
function SectionCard({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<Card className="p-5">
<div className="mb-5 border-b border-border-subtle pb-4">
<div className="dashboard-section-label mb-2">Settings</div>
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-muted">
{description}
</p>
</div>
<div className="space-y-4">{children}</div>
</Card>
);
}
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 rounded-sm border border-border-subtle bg-black/10 p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-xl">
<div className="text-sm font-medium text-text-primary">{label}</div>
<p className="mt-1 text-xs leading-5 text-text-muted">{description}</p>
</div>
<div className="w-full max-w-md">{children}</div>
</div>
);
}
export default function SettingsPage() {
const { settings, setSettings, serverInfo, setServerInfo } = useAppStore();
const [activeSection, setActiveSection] = useState<SectionId>("dashboard");
const [localSettings, setLocalSettings] =
useState<LocalDashboardSettings>(settings);
const [backendState, setBackendState] = useState<BackendConfigState | null>(
null,
);
const [backendConfig, setBackendConfig] = useState<BackendConfig | null>(
null,
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generatingToken, setGeneratingToken] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const [configState, health] = await Promise.all([
api.fetchBackendConfig(),
api.fetchHealth().catch(() => null),
]);
setBackendState(configState);
setBackendConfig(configState.config);
if (health) {
setServerInfo(health);
}
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to load settings";
setError(message);
} finally {
setLoading(false);
}
};
load();
}, [setServerInfo]);
const hasDashboardChanges = useMemo(
() => JSON.stringify(localSettings) !== JSON.stringify(settings),
[localSettings, settings],
);
const hasBackendChanges = useMemo(
() =>
Boolean(
backendConfig &&
backendState &&
JSON.stringify(backendConfig) !== JSON.stringify(backendState.config),
),
[backendConfig, backendState],
);
const hasChanges = hasDashboardChanges || hasBackendChanges;
const restartRequired =
backendState?.restartRequired || serverInfo?.restartRequired || false;
const restartReasons =
backendState?.restartReasons || serverInfo?.restartReasons || [];
const sensitiveEndpointsEnabled = backendConfig
? [
backendConfig.security.allowEvaluate,
backendConfig.security.allowMacro,
backendConfig.security.allowScreencast,
backendConfig.security.allowDownload,
backendConfig.security.allowUpload,
].some(Boolean)
: false;
const apiTokenMissing = backendConfig
? backendConfig.server.token.trim() === ""
: false;
const idpiEnabled = backendConfig
? backendConfig.security.idpi.enabled
: false;
const idpiAllowedDomains = backendConfig
? backendConfig.security.idpi.allowedDomains
: [];
const idpiWildcard = idpiAllowedDomains.includes("*");
const idpiDomainsConfigured = idpiAllowedDomains.length > 0 && !idpiWildcard;
const updateBackendSection = <K extends keyof BackendConfig>(
section: K,
patch: Partial<BackendConfig[K]>,
) => {
setBackendConfig((current) =>
current
? {
...current,
[section]: { ...current[section], ...patch },
}
: current,
);
};
const handleReset = () => {
if (backendState) {
setBackendConfig(backendState.config);
}
setLocalSettings(settings);
setError("");
setNotice("");
};
const handleGenerateToken = async () => {
if (!backendConfig) {
return;
}
setGeneratingToken(true);
setError("");
setNotice("");
try {
const token = await api.generateBackendToken();
updateBackendSection("server", { token });
setNotice("Generated a new API token. Save changes to persist it.");
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to generate API token";
setError(message);
} finally {
setGeneratingToken(false);
}
};
const handleSave = async () => {
if (!hasChanges || !backendConfig) return;
setSaving(true);
setError("");
setNotice("");
try {
if (hasDashboardChanges) {
setSettings(localSettings);
}
if (hasBackendChanges) {
const saved = await api.saveBackendConfig(backendConfig);
setBackendState(saved);
setBackendConfig(saved.config);
setNotice(
saved.restartRequired
? "Backend config saved. Dynamic changes were applied where possible. Restart advised for server-level changes."
: "Backend config saved. Dynamic changes were applied where possible.",
);
}
const health = await api.fetchHealth().catch(() => null);
if (health) {
setServerInfo(health);
}
if (!hasBackendChanges) {
setNotice("Dashboard preferences saved in this browser.");
}
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to save settings";
setError(message);
} finally {
setSaving(false);
}
};
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="sticky top-0 z-10 border-b border-border-subtle bg-bg-surface/95 px-4 py-3 backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="dashboard-mono text-sm text-text-secondary">
Server Version: {serverInfo?.version || "Unknown version"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{restartRequired && (
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-warning">
Restart required
</div>
)}
<Button
variant="secondary"
onClick={handleReset}
disabled={!hasChanges || saving}
>
Reset
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!hasChanges || saving || !backendConfig}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
{(error || notice || restartReasons.length > 0) && (
<div className="mt-3 flex flex-col gap-2">
{error && (
<div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div className="rounded-sm border border-primary/30 bg-primary/10 px-3 py-2 text-sm text-primary">
{notice}
</div>
)}
{restartRequired && restartReasons.length > 0 && (
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-sm text-warning">
Restart needed for: {restartReasons.join(", ")}.
</div>
)}
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-6 overflow-hidden p-4 lg:flex-row lg:p-6">
<aside className="flex w-full shrink-0 flex-col gap-4 overflow-y-auto lg:w-72">
<Card className="p-3">
<div className="dashboard-section-label mb-3">Sections</div>
<div className="flex flex-col gap-1.5">
{sections.map((section) => (
<button
key={section.id}
type="button"
className={`rounded-sm border px-3 py-2.5 text-left transition-all ${
activeSection === section.id
? "border-primary/30 bg-primary/10 text-text-primary"
: "border-transparent text-text-secondary hover:border-border-subtle hover:bg-bg-elevated hover:text-text-primary"
}`}
onClick={() => setActiveSection(section.id)}
>
<div className="text-sm font-medium">{section.label}</div>
<div className="mt-1 text-xs leading-5 text-text-muted">
{section.description}
</div>
</button>
))}
</div>
</Card>
</aside>
<div className="flex-1 overflow-y-auto pr-1">
{loading || !backendConfig ? (
<Card className="p-6">
<div className="text-sm text-text-muted">Loading settings…</div>
</Card>
) : (
<>
{activeSection === "dashboard" && (
<SectionCard
title="Dashboard Preferences"
description="These controls affect this dashboard UI only. They are stored locally in your browser and do not require a backend restart."
>
<SettingRow
label="Screencast frame rate"
description="Controls how often live previews request new frames."
>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={15}
value={localSettings.screencast.fps}
onChange={(e) =>
setLocalSettings({
...localSettings,
screencast: {
...localSettings.screencast,
fps: Number(e.target.value),
},
})
}
className="w-full"
/>
<span className="dashboard-mono w-16 text-right text-sm text-text-secondary">
{localSettings.screencast.fps} fps
</span>
</div>
</SettingRow>
<SettingRow
label="Screencast quality"
description="JPEG quality for tab preview streams."
>
<div className="flex items-center gap-3">
<input
type="range"
min={10}
max={80}
value={localSettings.screencast.quality}
onChange={(e) =>
setLocalSettings({
...localSettings,
screencast: {
...localSettings.screencast,
quality: Number(e.target.value),
},
})
}
className="w-full"
/>
<span className="dashboard-mono w-16 text-right text-sm text-text-secondary">
{localSettings.screencast.quality}%
</span>
</div>
</SettingRow>
<SettingRow
label="Screencast width"
description="Maximum preview width for live tiles."
>
<select
value={localSettings.screencast.maxWidth}
onChange={(e) =>
setLocalSettings({
...localSettings,
screencast: {
...localSettings.screencast,
maxWidth: Number(e.target.value),
},
})
}
className={selectClass}
>
{[400, 600, 800, 1024, 1280].map((width) => (
<option key={width} value={width}>
{width}px
</option>
))}
</select>
</SettingRow>
<SettingRow
label="Memory metrics"
description="Enable per-tab heap collection in the dashboard. Useful for debugging, but heavier."
>
<label className="flex items-center justify-end gap-3 text-sm text-text-secondary">
<input
type="checkbox"
checked={localSettings.monitoring.memoryMetrics}
onChange={(e) =>
setLocalSettings({
...localSettings,
monitoring: {
...localSettings.monitoring,
memoryMetrics: e.target.checked,
},
})
}
className="h-4 w-4"
/>
Enable
</label>
</SettingRow>
<SettingRow
label="Polling interval"
description="How frequently the dashboard asks the backend for fresh metrics."
>
<div className="flex items-center gap-3">
<input
type="range"
min={5}
max={120}
step={5}
value={localSettings.monitoring.pollInterval}
onChange={(e) =>
setLocalSettings({
...localSettings,
monitoring: {
...localSettings.monitoring,
pollInterval: Number(e.target.value),
},
})
}
className="w-full"
/>
<span className="dashboard-mono w-16 text-right text-sm text-text-secondary">
{localSettings.monitoring.pollInterval}s
</span>
</div>
</SettingRow>
</SectionCard>
)}
{activeSection === "defaults" && (
<SectionCard
title="Instance Defaults"
description="These values are written to config and used for new managed instances. Existing running instances keep their current runtime."
>
<SettingRow
label="Mode"
description="Default browser mode for new launches."
>
<select
value={backendConfig.instanceDefaults.mode}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
mode: e.target
.value as BackendConfig["instanceDefaults"]["mode"],
})
}
className={selectClass}
>
<option value="headless">Headless</option>
<option value="headed">Headed</option>
</select>
</SettingRow>
<SettingRow
label="Stealth level"
description="Fingerprint hardening profile for new instances."
>
<select
value={backendConfig.instanceDefaults.stealthLevel}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
stealthLevel: e.target
.value as BackendConfig["instanceDefaults"]["stealthLevel"],
})
}
className={selectClass}
>
<option value="light">Light</option>
<option value="medium">Medium</option>
<option value="full">Full</option>
</select>
</SettingRow>
<SettingRow
label="Tab eviction policy"
description="How PinchTab behaves when a managed instance reaches its tab limit."
>
<select
value={backendConfig.instanceDefaults.tabEvictionPolicy}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
tabEvictionPolicy: e.target
.value as BackendConfig["instanceDefaults"]["tabEvictionPolicy"],
})
}
className={selectClass}
>
<option value="reject">Reject new tabs</option>
<option value="close_oldest">Close oldest</option>
<option value="close_lru">
Close least recently used
</option>
</select>
</SettingRow>
<SettingRow
label="Max tabs"
description="Maximum number of tabs per managed instance."
>
<input
type="number"
min={1}
value={backendConfig.instanceDefaults.maxTabs}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
maxTabs: Number(e.target.value),
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Max parallel tabs"
description="Set to 0 to auto-detect from CPU count."
>
<input
type="number"
min={0}
value={backendConfig.instanceDefaults.maxParallelTabs}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
maxParallelTabs: Number(e.target.value),
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Timezone"
description="Optional timezone override for launched instances."
>
<input
value={backendConfig.instanceDefaults.timezone}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
timezone: e.target.value,
})
}
placeholder="Europe/Rome"
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="User agent"
description="Optional override applied to new managed instances."
>
<input
value={backendConfig.instanceDefaults.userAgent}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
userAgent: e.target.value,
})
}
placeholder="Custom user agent"
className={fieldClass}
/>
</SettingRow>
{[
["blockImages", "Block images"],
["blockMedia", "Block media"],
["blockAds", "Block ads"],
["noAnimations", "Disable CSS animations"],
["noRestore", "Skip session restore"],
].map(([key, label]) => (
<SettingRow
key={key}
label={label}
description="Applies to newly launched managed instances."
>
<label className="flex items-center justify-end gap-3 text-sm text-text-secondary">
<input
type="checkbox"
checked={
backendConfig.instanceDefaults[
key as keyof BackendConfig["instanceDefaults"]
] as boolean
}
onChange={(e) =>
updateBackendSection("instanceDefaults", {
[key]: e.target.checked,
} as Partial<BackendConfig["instanceDefaults"]>)
}
className="h-4 w-4"
/>
Enable
</label>
</SettingRow>
))}
</SectionCard>
)}
{activeSection === "orchestration" && (
<SectionCard
title="Orchestration"
description="Port range and allocation policy can be applied immediately for future launches. Strategy and restart-policy changes require a dashboard restart because strategy routes and lifecycle state are registered at startup."
>
<SettingRow
label="Strategy"
description="Controls how shorthand routes are routed in dashboard mode."
>
<select
value={backendConfig.multiInstance.strategy}
onChange={(e) =>
updateBackendSection("multiInstance", {
strategy: e.target
.value as BackendConfig["multiInstance"]["strategy"],
})
}
className={selectClass}
>
<option value="always-on">Always on</option>
<option value="simple">Simple</option>
<option value="explicit">Explicit</option>
<option value="simple-autorestart">
Simple autorestart
</option>
</select>
</SettingRow>
<SettingRow
label="Allocation policy"
description="Determines how running instances are chosen for shorthand requests."
>
<select
value={backendConfig.multiInstance.allocationPolicy}
onChange={(e) =>
updateBackendSection("multiInstance", {
allocationPolicy: e.target
.value as BackendConfig["multiInstance"]["allocationPolicy"],
})
}
className={selectClass}
>
<option value="fcfs">First available</option>
<option value="round_robin">Round robin</option>
<option value="random">Random</option>
</select>
</SettingRow>
<SettingRow
label="Instance port start"
description="Lower bound for auto-allocated instance ports."
>
<input
type="number"
min={1}
value={backendConfig.multiInstance.instancePortStart}
onChange={(e) =>
updateBackendSection("multiInstance", {
instancePortStart: Number(e.target.value),
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Instance port end"
description="Upper bound for auto-allocated instance ports."
>
<input
type="number"
min={1}
value={backendConfig.multiInstance.instancePortEnd}
onChange={(e) =>
updateBackendSection("multiInstance", {
instancePortEnd: Number(e.target.value),
})
}
className={fieldClass}
/>
</SettingRow>
{(backendConfig.multiInstance.strategy === "always-on" ||
backendConfig.multiInstance.strategy ===
"simple-autorestart") && (
<>
<SettingRow
label="Max restarts"
description="Maximum restart attempts. Use -1 for unlimited, 0 for no restarts."
>
<input
type="number"
min={-1}
value={
backendConfig.multiInstance.restart.maxRestarts
}
onChange={(e) =>
updateBackendSection("multiInstance", {
restart: {
...backendConfig.multiInstance.restart,
maxRestarts: Number(e.target.value),
},
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Initial backoff"
description="Delay in seconds before the first restart attempt."
>
<input
type="number"
min={1}
value={
backendConfig.multiInstance.restart.initBackoffSec
}
onChange={(e) =>
updateBackendSection("multiInstance", {
restart: {
...backendConfig.multiInstance.restart,
initBackoffSec: Number(e.target.value),
},
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Max backoff"
description="Upper bound in seconds for exponential restart backoff."
>
<input
type="number"
min={1}
value={
backendConfig.multiInstance.restart.maxBackoffSec
}
onChange={(e) =>
updateBackendSection("multiInstance", {
restart: {
...backendConfig.multiInstance.restart,
maxBackoffSec: Number(e.target.value),
},
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Stable after"
description="Seconds the instance must stay healthy before the restart counter resets."
>
<input
type="number"
min={1}
value={
backendConfig.multiInstance.restart.stableAfterSec
}
onChange={(e) =>
updateBackendSection("multiInstance", {
restart: {
...backendConfig.multiInstance.restart,
stableAfterSec: Number(e.target.value),
},
})
}
className={fieldClass}
/>
</SettingRow>
</>
)}
</SectionCard>
)}
{activeSection === "security" && (
<SectionCard
title="Security"
description="These controls define what risky capabilities PinchTab exposes."
>
<div
className={`rounded-sm px-4 py-3 text-sm leading-6 ${
sensitiveEndpointsEnabled
? "border border-destructive/35 bg-destructive/10 text-destructive"
: "border border-warning/25 bg-warning/10 text-warning"
}`}
>
{sensitiveEndpointsEnabled
? "One or more sensitive endpoint families are enabled. Features like script execution, downloads, uploads, and live capture can expose high-risk capabilities. Only enable them in trusted environments. You are responsible for securing network access, authentication, and downstream use."
: "These endpoint families can expose high-risk capabilities when enabled. Only turn them on in trusted environments, and only when you accept responsibility for network access, authentication, and downstream use."}
</div>
{securityEndpointRows.map(([key, label]) => (
<SettingRow
key={key}
label={label}
description="Controls whether the corresponding endpoint family is enabled."
>
<label className="flex items-center justify-end gap-3 text-sm text-text-secondary">
<input
type="checkbox"
checked={backendConfig.security[key]}
onChange={(e) =>
updateBackendSection("security", {
[key]: e.target.checked,
} as Partial<
Pick<BackendSecurityConfig, SecurityEndpointKey>
>)
}
className="h-4 w-4"
/>
Enable
</label>
</SettingRow>
))}
</SectionCard>
)}
{activeSection === "security-idpi" && (
<SectionCard
title="Security IDPI"
description="Indirect prompt injection controls restrict which websites are allowed and add protections around extracted content before it reaches downstream automation."
>
<div
className={`mb-4 rounded-sm px-4 py-3 text-sm leading-6 ${
!idpiEnabled || !idpiDomainsConfigured
? "border border-destructive/35 bg-destructive/10 text-destructive"
: idpiWildcard
? "border border-warning/25 bg-warning/10 text-warning"
: "border border-success/25 bg-success/10 text-success"
}`}
>
{!idpiEnabled
? "IDPI is disabled. Browser content is not being filtered by website allowlist or content protections."
: !idpiDomainsConfigured
? "The website whitelist is not set to a restricted domain list. This is the main IDPI defense and should be configured."
: idpiWildcard
? "The website whitelist contains '*', which effectively disables domain restriction."
: "IDPI is enforcing a specific website whitelist and content protections."}
</div>
{idpiToggleRows.map(([key, label, description]) => (
<SettingRow
key={key}
label={label}
description={description}
>
<label className="flex items-center justify-end gap-3 text-sm text-text-secondary">
<input
type="checkbox"
checked={backendConfig.security.idpi[key]}
onChange={(e) =>
updateBackendSection("security", {
idpi: {
...backendConfig.security.idpi,
[key]: e.target.checked,
},
})
}
className="h-4 w-4"
/>
Enable
</label>
</SettingRow>
))}
<SettingRow
label="Allowed websites"
description="Comma-separated domain allowlist for web content. Use exact hosts or patterns like *.example.com."
>
<div className="space-y-2">
<input
value={listToCsv(
backendConfig.security.idpi.allowedDomains,
)}
onChange={(e) =>
updateBackendSection("security", {
idpi: {
...backendConfig.security.idpi,
allowedDomains: csvToList(e.target.value),
},
})
}
className={fieldClass}
placeholder="127.0.0.1, localhost, ::1"
/>
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
Keep this list narrow. Empty or wildcard entries weaken
the main IDPI boundary.
</div>
</div>
</SettingRow>
<SettingRow
label="Custom patterns"
description="Optional comma-separated phrases to treat as suspicious prompt-injection content."
>
<input
value={listToCsv(
backendConfig.security.idpi.customPatterns,
)}
onChange={(e) =>
updateBackendSection("security", {
idpi: {
...backendConfig.security.idpi,
customPatterns: csvToList(e.target.value),
},
})
}
className={fieldClass}
placeholder="ignore previous instructions, exfiltrate data"
/>
</SettingRow>
</SectionCard>
)}
{activeSection === "profiles" && (
<SectionCard
title="Profiles"
description="Profile storage is host-level. Changing the base directory requires restart because the profile manager and orchestrator are created with it at boot."
>
<SettingRow
label="Profiles base directory"
description="Root directory where browser profiles are stored."
>
<input
value={backendConfig.profiles.baseDir}
onChange={(e) =>
updateBackendSection("profiles", {
baseDir: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Default profile"
description="Profile name used when the server needs an implicit default."
>
<input
value={backendConfig.profiles.defaultProfile}
onChange={(e) =>
updateBackendSection("profiles", {
defaultProfile: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
</SectionCard>
)}
{activeSection === "network" && (
<SectionCard
title="Network & Attach"
description="Port and bind changes require a restart. Token changes update request auth immediately because middleware reads runtime config live."
>
<SettingRow
label="Server port"
description="HTTP port for the dashboard process."
>
<input
value={backendConfig.server.port}
onChange={(e) =>
updateBackendSection("server", { port: e.target.value })
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Bind address"
description="Network interface the dashboard process binds to."
>
<input
value={backendConfig.server.bind}
onChange={(e) =>
updateBackendSection("server", { bind: e.target.value })
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="API token"
description="Bearer token required by authenticated requests when set. Leaving this empty means reachable clients are unauthenticated."
>
<div className="space-y-2">
<div className="flex gap-2">
<input
value={backendConfig.server.token}
onChange={(e) =>
updateBackendSection("server", {
token: e.target.value,
})
}
className={fieldClass}
/>
<Button
type="button"
variant="secondary"
onClick={handleGenerateToken}
disabled={generatingToken}
>
{generatingToken ? "Generating..." : "Generate"}
</Button>
</div>
{apiTokenMissing && (
<div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive">
No API token is set. Anyone who can reach this server
can access exposed endpoints. Keep it on trusted local
networks only, or set a strong token. You are
responsible for protecting access.
</div>
)}
</div>
</SettingRow>
<SettingRow
label="State directory"
description="Base state path used by managed child instances."
>
<input
value={backendConfig.server.stateDir}
onChange={(e) =>
updateBackendSection("server", {
stateDir: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Allow attach"
description="Permit attaching PinchTab to externally managed Chrome sessions."
>
<label className="flex items-center justify-end gap-3 text-sm text-text-secondary">
<input
type="checkbox"
checked={backendConfig.security.attach.enabled}
onChange={(e) =>
updateBackendSection("security", {
attach: {
...backendConfig.security.attach,
enabled: e.target.checked,
},
})
}
className="h-4 w-4"
/>
Enable
</label>
</SettingRow>
<SettingRow
label="Allowed attach hosts"
description="Comma-separated host allowlist for attach requests. Only include hosts you control and trust."
>
<div className="space-y-2">
<input
value={listToCsv(
backendConfig.security.attach.allowHosts,
)}
onChange={(e) =>
updateBackendSection("security", {
attach: {
...backendConfig.security.attach,
allowHosts: csvToList(e.target.value),
},
})
}
className={fieldClass}
/>
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
Hosts in this allowlist may be used for remote attach
requests. Broad or untrusted entries can expose external
Chrome sessions and browser contents.
</div>
</div>
</SettingRow>
<SettingRow
label="Allowed attach schemes"
description="Comma-separated scheme allowlist, usually ws and wss."
>
<input
value={listToCsv(
backendConfig.security.attach.allowSchemes,
)}
onChange={(e) =>
updateBackendSection("security", {
attach: {
...backendConfig.security.attach,
allowSchemes: csvToList(e.target.value),
},
})
}
className={fieldClass}
/>
</SettingRow>
</SectionCard>
)}
{activeSection === "browser" && (
<SectionCard
title="Browser Runtime"
description="These settings are written into the generated child config for new managed instances."
>
<SettingRow
label="Chrome version"
description="Version string used in generated UA/fingerprint defaults."
>
<input
value={backendConfig.browser.version}
onChange={(e) =>
updateBackendSection("browser", {
version: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Chrome binary"
description="Optional path override for the Chrome executable."
>
<input
value={backendConfig.browser.binary}
onChange={(e) =>
updateBackendSection("browser", {
binary: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Extra flags"
description="Additional Chrome flags appended when launching managed instances."
>
<input
value={backendConfig.browser.extraFlags}
onChange={(e) =>
updateBackendSection("browser", {
extraFlags: e.target.value,
})
}
className={fieldClass}
/>
</SettingRow>
<SettingRow
label="Extension paths"
description="Comma-separated extension directories to load."
>
<input
value={listToCsv(backendConfig.browser.extensionPaths)}
onChange={(e) =>
updateBackendSection("browser", {
extensionPaths: csvToList(e.target.value),
})
}
className={fieldClass}
/>
</SettingRow>
</SectionCard>
)}
{activeSection === "timeouts" && (
<SectionCard
title="Timeouts"
description="Runtime timing defaults written into new child configs. Existing running instances keep their current timeouts."
>
{[
[
"actionSec",
"Action timeout",
"Maximum time for action requests.",
],
[
"navigateSec",
"Navigate timeout",
"Maximum time for navigation requests.",
],
[
"shutdownSec",
"Shutdown timeout",
"Grace period before force-closing a child process.",
],
[
"waitNavMs",
"Wait-after-navigation delay",
"Post-navigation stabilization delay in milliseconds.",
],
].map(([key, label, description]) => (
<SettingRow
key={key}
label={label}
description={description}
>
<input
type="number"
min={0}
value={
backendConfig.timeouts[
key as keyof BackendConfig["timeouts"]
]
}
onChange={(e) =>
updateBackendSection("timeouts", {
[key]: Number(e.target.value),
} as Partial<BackendConfig["timeouts"]>)
}
className={fieldClass}
/>
</SettingRow>
))}
</SectionCard>
)}
</>
)}
</div>
</div>
</div>
);
}