Files
phishingclub/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte
T
Ronni Skansing c7b48e520c add proxy rewrite / replace multiline input
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-02-04 21:47:18 +01:00

3926 lines
111 KiB
Svelte

<script>
import { createEventDispatcher, onMount } from 'svelte';
import TextField from '$lib/components/TextField.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import TextareaField from '$lib/components/TextareaField.svelte';
import Search from '$lib/components/Search.svelte';
import jsyaml from '$lib/components/yaml/index.js';
export let config = null;
// basic info fields passed from parent
export let name = '';
export let description = '';
export let startURL = '';
const dispatch = createEventDispatcher();
// track if initial parse has happened
let initialized = false;
// track if we should skip the next reactive dispatch (used during mount)
let skipNextDispatch = false;
// default empty config structure
let configData = {
version: '0.0',
proxy: '',
global: {
tls: { mode: 'managed' },
access: { mode: 'private', on_deny: '' },
impersonate: { enabled: false, retain_ua: false },
variables: { enabled: false, allowed: [] },
capture: [],
rewrite: [],
response: [],
rewrite_urls: []
},
hosts: []
};
// valid proxy template variables that can be used in rewrite rules
const validProxyVariables = [
// recipient fields
'rID',
'FirstName',
'LastName',
'Email',
'To',
'Phone',
'ExtraIdentifier',
'Position',
'Department',
'City',
'Country',
'Misc',
// sender fields
'From',
'FromName',
'FromEmail',
'Subject',
// general fields
'BaseURL',
'URL',
// custom fields
'CustomField1',
'CustomField2',
'CustomField3',
'CustomField4'
];
// active tab for main sections
let activeTab = 'basic';
// expanded host index (-1 = none)
let expandedHostIndex = -1;
// host search
let hostSearchQuery = '';
// create a simple pagination-like object for the Search component
const hostSearchPagination = {
_search: '',
get search() {
return this._search;
},
set search(val) {
this._search = val;
hostSearchQuery = val || '';
},
onChange: (callback) => {
// not needed for our use case
return () => {};
}
};
$: filteredHosts = configData.hosts
.map((host, index) => ({ host, index }))
.filter(({ host }) => {
if (!hostSearchQuery.trim()) return true;
const query = hostSearchQuery.toLowerCase();
return (
(host.to || '').toLowerCase().includes(query) ||
(host.domain || '').toLowerCase().includes(query)
);
});
// active sub-tab per host - use reactive $: to track changes
let hostActiveTabs = {};
// reactive variable to force re-render when host tabs change
$: currentHostTab = hostActiveTabs[expandedHostIndex] || 'settings';
// unique id counter for form elements and rule tracking
let idCounter = 0;
const getUniqueId = (prefix) => `${prefix}-${idCounter++}`;
const getRuleId = () => `rule-${idCounter++}`;
// parse config only on initial mount
onMount(() => {
// skip the reactive dispatch that will fire when initialized becomes true
skipNextDispatch = true;
if (config) {
parseYamlConfig(config);
}
initialized = true;
});
// reactively dispatch change event whenever configData changes
$: if (initialized && configData) {
if (skipNextDispatch) {
skipNextDispatch = false;
} else {
const yaml = generateYaml();
dispatch('change', yaml);
}
}
// expose method to get current YAML (can be called by parent if needed)
export function getYaml() {
return generateYaml();
}
// expose import/export methods for parent to call
export function triggerImport() {
fileInput?.click();
}
export { exportConfig };
// expose method to validate and navigate to first error
// returns { valid: boolean, errors: object }
export function validate() {
validationErrors = validateConfig();
const errorKeys = Object.keys(validationErrors);
if (errorKeys.length === 0) {
return { valid: true, errors: {} };
}
// navigate to first error
const firstErrorKey = errorKeys[0];
navigateToError(firstErrorKey);
return { valid: false, errors: validationErrors };
}
// navigate to the tab/host/sub-tab containing the error
function navigateToError(errorKey) {
const parts = errorKey.split('.');
if (parts[0] === 'global') {
// global error - switch to global tab
activeTab = 'global';
// determine which sub-tab
if (parts[1] === 'capture') {
globalRulesTab = 'capture';
} else if (parts[1] === 'rewrite') {
globalRulesTab = 'rewrite';
} else if (parts[1] === 'response') {
globalRulesTab = 'response';
} else if (parts[1] === 'rewrite_urls') {
globalRulesTab = 'urlrewrite';
}
} else if (parts[0] === 'hosts') {
// host error - switch to hosts tab and expand the host
activeTab = 'hosts';
const hostIndex = parseInt(parts[1], 10);
expandedHostIndex = hostIndex;
// determine which sub-tab
if (parts[2] === 'to' || parts[2] === 'domain') {
setHostActiveTab(hostIndex, 'settings');
} else if (parts[2] === 'capture') {
setHostActiveTab(hostIndex, 'capture');
} else if (parts[2] === 'rewrite') {
setHostActiveTab(hostIndex, 'rewrite');
} else if (parts[2] === 'response') {
setHostActiveTab(hostIndex, 'response');
} else if (parts[2] === 'rewrite_urls') {
setHostActiveTab(hostIndex, 'urlrewrite');
}
}
}
// parse YAML config using js-yaml library
function parseYamlConfig(yamlStr) {
if (!yamlStr || yamlStr.trim() === '') {
resetConfig();
return;
}
try {
const parsed = jsyaml.load(yamlStr);
if (parsed && typeof parsed === 'object') {
configData.version = String(parsed.version || '0.0');
configData.proxy = parsed.proxy || '';
if (parsed.global) {
configData.global.tls = parsed.global.tls || { mode: 'managed' };
configData.global.access = parsed.global.access || { mode: 'private', on_deny: '' };
configData.global.impersonate = parsed.global.impersonate || {
enabled: false,
retain_ua: false
};
configData.global.variables = parsed.global.variables || {
enabled: false,
allowed: []
};
configData.global.capture = (parsed.global.capture || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.response = (parsed.global.response || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.rewrite_urls = (parsed.global.rewrite_urls || []).map((r) => ({
...r,
_id: getRuleId()
}));
}
// extract hosts (keys that contain '.' and have a 'to' property)
const hosts = [];
for (const key of Object.keys(parsed)) {
if (
key !== 'version' &&
key !== 'proxy' &&
key !== 'global' &&
parsed[key] &&
typeof parsed[key] === 'object' &&
parsed[key].to
) {
const hostData = parsed[key];
hosts.push({
domain: key,
to: hostData.to || '',
scheme: hostData.scheme || 'https',
tls: { mode: hostData.tls?.mode || '' },
access: {
mode: hostData.access?.mode || '',
on_deny: hostData.access?.on_deny || ''
},
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
});
}
}
configData.hosts = hosts;
// expand first host if exists
if (hosts.length > 0 && expandedHostIndex === -1) {
expandedHostIndex = 0;
}
}
} catch (e) {
console.warn('Failed to parse YAML config:', e);
}
}
function resetConfig() {
configData = {
version: '0.0',
proxy: '',
global: {
tls: { mode: 'managed' },
access: { mode: 'private', on_deny: '' },
impersonate: { enabled: false, retain_ua: false },
variables: { enabled: false, allowed: [] },
capture: [],
rewrite: [],
response: [],
rewrite_urls: []
},
hosts: []
};
expandedHostIndex = -1;
}
// helper to remove internal _id fields before serialization
function stripIds(obj) {
if (Array.isArray(obj)) {
return obj.map(stripIds);
}
if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
if (key !== '_id') {
result[key] = stripIds(value);
}
}
return result;
}
return obj;
}
// helper to remove empty values from objects for cleaner YAML output
function cleanObject(obj) {
if (Array.isArray(obj)) {
return obj.map(cleanObject);
}
if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const cleaned = cleanObject(value);
// skip empty strings, empty arrays, empty objects, null, undefined
if (
cleaned !== '' &&
cleaned !== null &&
cleaned !== undefined &&
!(Array.isArray(cleaned) && cleaned.length === 0) &&
!(
typeof cleaned === 'object' &&
!Array.isArray(cleaned) &&
Object.keys(cleaned).length === 0
)
) {
result[key] = cleaned;
}
}
return result;
}
return obj;
}
// generate YAML using js-yaml library
function generateYaml() {
// build the config object
const output = {};
output.version = configData.version || '0.0';
if (configData.proxy) {
output.proxy = configData.proxy;
}
// build global section
const global = {};
if (configData.global.tls?.mode) {
global.tls = { mode: configData.global.tls.mode };
}
if (configData.global.access?.mode) {
global.access = { mode: configData.global.access.mode };
if (configData.global.access.on_deny) {
global.access.on_deny = configData.global.access.on_deny;
}
}
if (configData.global.impersonate?.enabled) {
global.impersonate = {
enabled: configData.global.impersonate.enabled
};
if (configData.global.impersonate.retain_ua) {
global.impersonate.retain_ua = configData.global.impersonate.retain_ua;
}
}
if (configData.global.variables?.enabled) {
global.variables = {
enabled: configData.global.variables.enabled
};
if (configData.global.variables.allowed?.length > 0) {
global.variables.allowed = configData.global.variables.allowed;
}
}
// filter and add global rules (only include touched/valid rules)
const globalCapture = (configData.global.capture || []).filter(isCaptureRuleTouched);
if (globalCapture.length > 0) {
global.capture = stripIds(globalCapture);
}
const globalRewrite = (configData.global.rewrite || []).filter(isRewriteRuleTouched);
if (globalRewrite.length > 0) {
global.rewrite = stripIds(globalRewrite);
}
const globalResponse = (configData.global.response || []).filter(isResponseRuleTouched);
if (globalResponse.length > 0) {
global.response = stripIds(globalResponse);
}
const globalRewriteUrls = (configData.global.rewrite_urls || []).filter(
isUrlRewriteRuleTouched
);
if (globalRewriteUrls.length > 0) {
global.rewrite_urls = stripIds(globalRewriteUrls);
}
if (Object.keys(global).length > 0) {
output.global = global;
}
// add hosts
for (const host of configData.hosts) {
if (!host.domain || !host.to) continue;
const hostObj = { to: host.to };
if (host.scheme && host.scheme !== 'https') {
hostObj.scheme = host.scheme;
}
if (host.tls?.mode) {
hostObj.tls = { mode: host.tls.mode };
}
if (host.access?.mode) {
hostObj.access = { mode: host.access.mode };
if (host.access.on_deny) {
hostObj.access.on_deny = host.access.on_deny;
}
}
// filter and add host rules (only include touched/valid rules)
const hostCapture = (host.capture || []).filter(isCaptureRuleTouched);
if (hostCapture.length > 0) {
hostObj.capture = stripIds(hostCapture);
}
const hostRewrite = (host.rewrite || []).filter(isRewriteRuleTouched);
if (hostRewrite.length > 0) {
hostObj.rewrite = stripIds(hostRewrite);
}
const hostResponse = (host.response || []).filter(isResponseRuleTouched);
if (hostResponse.length > 0) {
hostObj.response = stripIds(hostResponse);
}
const hostRewriteUrls = (host.rewrite_urls || []).filter(isUrlRewriteRuleTouched);
if (hostRewriteUrls.length > 0) {
hostObj.rewrite_urls = stripIds(hostRewriteUrls);
}
output[host.domain] = cleanObject(hostObj);
}
// serialize to YAML with js-yaml
return jsyaml.dump(output, {
indent: 2,
lineWidth: 120, // allow block scalars for multiline strings
quotingType: "'", // prefer single quotes
forceQuotes: false, // only quote when necessary
noRefs: true // don't use YAML references
});
}
// host management
function addHost() {
configData.hosts = [
...configData.hosts,
{
domain: '',
to: '',
scheme: 'https',
tls: { mode: '' },
access: { mode: '', on_deny: '' },
capture: [],
rewrite: [],
response: [],
rewrite_urls: []
}
];
expandedHostIndex = configData.hosts.length - 1;
hostActiveTabs[expandedHostIndex] = 'settings';
}
function removeHost(index) {
configData.hosts = configData.hosts.filter((_, i) => i !== index);
if (expandedHostIndex >= configData.hosts.length) {
expandedHostIndex = configData.hosts.length - 1;
}
if (expandedHostIndex < 0 && configData.hosts.length > 0) {
expandedHostIndex = 0;
}
}
function duplicateHost(index) {
const host = configData.hosts[index];
const newHost = JSON.parse(JSON.stringify(host));
// assign new IDs to all rules in the duplicated host
if (newHost.capture) newHost.capture = newHost.capture.map((r) => ({ ...r, _id: getRuleId() }));
if (newHost.rewrite) newHost.rewrite = newHost.rewrite.map((r) => ({ ...r, _id: getRuleId() }));
if (newHost.response)
newHost.response = newHost.response.map((r) => ({ ...r, _id: getRuleId() }));
if (newHost.rewrite_urls)
newHost.rewrite_urls = newHost.rewrite_urls.map((r) => ({ ...r, _id: getRuleId() }));
configData.hosts = [...configData.hosts, newHost];
expandedHostIndex = configData.hosts.length - 1;
}
// global rule management
function addGlobalCaptureRule() {
configData.global.capture = [
...configData.global.capture,
{
_id: getRuleId(),
name: '',
method: 'POST',
path: '',
find: '',
engine: 'regex',
from: 'request_body',
required: false
}
];
}
function removeGlobalCaptureRule(index) {
configData.global.capture = configData.global.capture.filter((_, i) => i !== index);
}
function addGlobalRewriteRule() {
configData.global.rewrite = [
...configData.global.rewrite,
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
];
}
function removeGlobalRewriteRule(index) {
configData.global.rewrite = configData.global.rewrite.filter((_, i) => i !== index);
}
function addGlobalResponseRule() {
configData.global.response = [
...configData.global.response,
{ _id: getRuleId(), path: '', status: 200, headers: {}, body: '', forward: false }
];
}
function removeGlobalResponseRule(index) {
configData.global.response = configData.global.response.filter((_, i) => i !== index);
}
function addGlobalRewriteUrlRule() {
configData.global.rewrite_urls = [
...configData.global.rewrite_urls,
{ _id: getRuleId(), find: '', replace: '', query: '', filter: '' }
];
}
function removeGlobalRewriteUrlRule(index) {
configData.global.rewrite_urls = configData.global.rewrite_urls.filter((_, i) => i !== index);
}
// host rule management
function addHostCaptureRule(hostIndex) {
configData.hosts[hostIndex].capture = [
...(configData.hosts[hostIndex].capture || []),
{
_id: getRuleId(),
name: '',
method: 'POST',
path: '',
find: '',
engine: 'regex',
from: 'request_body',
required: false
}
];
configData.hosts = [...configData.hosts];
}
function removeHostCaptureRule(hostIndex, ruleIndex) {
configData.hosts[hostIndex].capture = configData.hosts[hostIndex].capture.filter(
(_, i) => i !== ruleIndex
);
configData.hosts = [...configData.hosts];
}
function addHostRewriteRule(hostIndex) {
configData.hosts[hostIndex].rewrite = [
...(configData.hosts[hostIndex].rewrite || []),
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
];
configData.hosts = [...configData.hosts];
}
function removeHostRewriteRule(hostIndex, ruleIndex) {
configData.hosts[hostIndex].rewrite = configData.hosts[hostIndex].rewrite.filter(
(_, i) => i !== ruleIndex
);
configData.hosts = [...configData.hosts];
}
function addHostResponseRule(hostIndex) {
configData.hosts[hostIndex].response = [
...(configData.hosts[hostIndex].response || []),
{ _id: getRuleId(), path: '', status: 200, headers: {}, body: '', forward: false }
];
configData.hosts = [...configData.hosts];
}
function removeHostResponseRule(hostIndex, ruleIndex) {
configData.hosts[hostIndex].response = configData.hosts[hostIndex].response.filter(
(_, i) => i !== ruleIndex
);
configData.hosts = [...configData.hosts];
}
function addHostRewriteUrlRule(hostIndex) {
configData.hosts[hostIndex].rewrite_urls = [
...(configData.hosts[hostIndex].rewrite_urls || []),
{ _id: getRuleId(), find: '', replace: '', query: '', filter: '' }
];
configData.hosts = [...configData.hosts];
}
function removeHostRewriteUrlRule(hostIndex, ruleIndex) {
configData.hosts[hostIndex].rewrite_urls = configData.hosts[hostIndex].rewrite_urls.filter(
(_, i) => i !== ruleIndex
);
configData.hosts = [...configData.hosts];
}
// response headers management
function addResponseHeader(rule) {
if (!rule.headers) rule.headers = {};
const newKey = `Header-${Object.keys(rule.headers).length + 1}`;
rule.headers[newKey] = '';
configData = configData;
}
function removeResponseHeader(rule, key) {
delete rule.headers[key];
configData = configData;
}
function updateResponseHeaderKey(rule, oldKey, newKey) {
if (oldKey !== newKey && newKey) {
const value = rule.headers[oldKey];
delete rule.headers[oldKey];
rule.headers[newKey] = value;
configData = configData;
}
}
// options
const tlsModes = [
{ value: 'managed', label: 'Managed' },
{ value: 'self-signed', label: 'Self-signed' }
];
const tlsModesWithEmpty = [
{ value: '', label: '(Use global default)' },
{ value: 'managed', label: 'Managed' },
{ value: 'self-signed', label: 'Self-signed' }
];
const accessModes = [
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' }
];
const accessModesWithEmpty = [
{ value: '', label: '(Use global default)' },
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' }
];
const schemes = [
{ value: 'https', label: 'HTTPS' },
{ value: 'http', label: 'HTTP' }
];
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
const fromOptions = [
{ value: 'request_body', label: 'Request Body' },
{ value: 'request_header', label: 'Request Header' },
{ value: 'response_body', label: 'Response Body' },
{ value: 'response_header', label: 'Response Header' },
{ value: 'any', label: 'Any' }
];
const headerFromOptions = [
{ value: 'request_header', label: 'Request Header' },
{ value: 'response_header', label: 'Response Header' }
];
// get from options based on engine - cookie ignores from, header only uses header options
function getFromOptionsForEngine(engine) {
if (engine === 'cookie') return [];
if (engine === 'header') return headerFromOptions;
return fromOptions;
}
// get default 'from' value for a given engine
function getDefaultFromForEngine(engine) {
if (engine === 'cookie') return '';
if (engine === 'header') return 'request_header';
return 'request_body';
}
// handle engine change - always reset 'from' to default for consistency
function handleCaptureEngineChange(rule, newEngine) {
rule.engine = newEngine;
rule.from = getDefaultFromForEngine(newEngine);
configData = configData;
}
const captureEngines = [
{ value: 'regex', label: 'Regex' },
{ value: 'header', label: 'Header' },
{ value: 'cookie', label: 'Cookie' },
{ value: 'json', label: 'JSON' },
{ value: 'form', label: 'Form' },
{ value: 'urlencoded', label: 'URL Encoded' },
{ value: 'formdata', label: 'Form Data' },
{ value: 'multipart', label: 'Multipart' }
];
const engines = [
{ value: 'regex', label: 'Regex' },
{ value: 'dom', label: 'DOM' }
];
const domActions = [
{ value: 'setText', label: 'Set Text' },
{ value: 'setHtml', label: 'Set HTML' },
{ value: 'setAttr', label: 'Set Attribute' },
{ value: 'removeAttr', label: 'Remove Attribute' },
{ value: 'addClass', label: 'Add Class' },
{ value: 'removeClass', label: 'Remove Class' },
{ value: 'remove', label: 'Remove' }
];
const targets = [
{ value: 'first', label: 'First' },
{ value: 'last', label: 'Last' },
{ value: 'all', label: 'All' }
];
// validation errors
let validationErrors = {};
// helper to get find value as string (find can be string or array)
function getFindAsString(find) {
if (!find) return '';
if (typeof find === 'string') return find;
if (Array.isArray(find) && find.length > 0) return String(find[0]);
return '';
}
// helper to check if find has a value (find can be string or array)
function hasFindValue(find) {
if (!find) return false;
if (typeof find === 'string') return !!find.trim();
if (Array.isArray(find)) return find.length > 0 && find.some((f) => f && String(f).trim());
return false;
}
// helper to check if a rule has been touched (user started filling it in)
function isCaptureRuleTouched(rule) {
return !!(rule.name?.trim() || rule.path?.trim() || hasFindValue(rule.find));
}
function isRewriteRuleTouched(rule) {
return !!(rule.find?.trim() || rule.replace?.trim());
}
function isResponseRuleTouched(rule) {
return !!(rule.path?.trim() || rule.body?.trim() || rule.status);
}
function isUrlRewriteRuleTouched(rule) {
return !!(rule.find?.trim() || rule.replace?.trim());
}
function isHostTouched(host) {
return !!(host.to?.trim() || host.domain?.trim());
}
// validate all rules and return errors object
function validateConfig() {
const errors = {};
// validate capture rules (global) - only if touched
configData.global.capture.forEach((rule, i) => {
if (!isCaptureRuleTouched(rule)) return;
const prefix = `global.capture.${i}`;
if (!rule.name?.trim()) {
errors[`${prefix}.name`] = 'Name is required';
}
if (!rule.path?.trim()) {
errors[`${prefix}.path`] = 'Path is required';
}
// find is required except for path-based navigation tracking (path has value, find empty)
if (!hasFindValue(rule.find) && !rule.path?.trim()) {
errors[`${prefix}.find`] = 'Find pattern is required';
}
});
// validate rewrite rules (global) - only if touched
configData.global.rewrite.forEach((rule, i) => {
if (!isRewriteRuleTouched(rule)) return;
const prefix = `global.rewrite.${i}`;
if (!rule.find?.trim()) {
errors[`${prefix}.find`] = 'Find is required';
}
if (rule.engine === 'dom') {
if (!rule.action) {
errors[`${prefix}.action`] = 'Action is required for DOM engine';
}
// replace is required for most actions except 'remove'
if (rule.action && rule.action !== 'remove' && !rule.replace?.trim()) {
errors[`${prefix}.replace`] = `Replace is required for ${rule.action}`;
}
}
});
// validate response rules (global) - only if touched
configData.global.response.forEach((rule, i) => {
if (!isResponseRuleTouched(rule)) return;
const prefix = `global.response.${i}`;
if (!rule.path?.trim()) {
errors[`${prefix}.path`] = 'Path is required';
}
});
// validate URL rewrite rules (global) - only if touched
configData.global.rewrite_urls.forEach((rule, i) => {
if (!isUrlRewriteRuleTouched(rule)) return;
const prefix = `global.rewrite_urls.${i}`;
if (!rule.find?.trim()) {
errors[`${prefix}.find`] = 'Find pattern is required';
}
if (!rule.replace?.trim()) {
errors[`${prefix}.replace`] = 'Replace path is required';
}
});
// validate hosts - only if touched
configData.hosts.forEach((host, hostIndex) => {
if (!isHostTouched(host)) return;
const hostPrefix = `hosts.${hostIndex}`;
if (!host.to?.trim()) {
errors[`${hostPrefix}.to`] = 'Phishing domain is required';
}
if (!host.domain?.trim()) {
errors[`${hostPrefix}.domain`] = 'Target domain is required';
}
// validate capture rules (host) - only if touched
(host.capture || []).forEach((rule, i) => {
if (!isCaptureRuleTouched(rule)) return;
const prefix = `${hostPrefix}.capture.${i}`;
if (!rule.name?.trim()) {
errors[`${prefix}.name`] = 'Name is required';
}
if (!rule.path?.trim()) {
errors[`${prefix}.path`] = 'Path is required';
}
// find is required except for path-based navigation tracking (path has value, find empty)
if (!hasFindValue(rule.find) && !rule.path?.trim()) {
errors[`${prefix}.find`] = 'Find pattern is required';
}
});
// validate rewrite rules (host) - only if touched
(host.rewrite || []).forEach((rule, i) => {
if (!isRewriteRuleTouched(rule)) return;
const prefix = `${hostPrefix}.rewrite.${i}`;
if (!rule.find?.trim()) {
errors[`${prefix}.find`] = 'Find is required';
}
if (rule.engine === 'dom') {
if (!rule.action) {
errors[`${prefix}.action`] = 'Action is required for DOM engine';
}
if (rule.action && rule.action !== 'remove' && !rule.replace?.trim()) {
errors[`${prefix}.replace`] = `Replace is required for ${rule.action}`;
}
}
});
// validate response rules (host) - only if touched
(host.response || []).forEach((rule, i) => {
if (!isResponseRuleTouched(rule)) return;
const prefix = `${hostPrefix}.response.${i}`;
if (!rule.path?.trim()) {
errors[`${prefix}.path`] = 'Path is required';
}
});
// validate URL rewrite rules (host) - only if touched
(host.rewrite_urls || []).forEach((rule, i) => {
if (!isUrlRewriteRuleTouched(rule)) return;
const prefix = `${hostPrefix}.rewrite_urls.${i}`;
if (!rule.find?.trim()) {
errors[`${prefix}.find`] = 'Find pattern is required';
}
if (!rule.replace?.trim()) {
errors[`${prefix}.replace`] = 'Replace path is required';
}
});
});
return errors;
}
// reactively validate when configData changes
$: if (initialized && configData) {
validationErrors = validateConfig();
}
// helper to check if a field has an error
function hasError(path) {
return !!validationErrors[path];
}
// helper to get error message
function getError(path) {
return validationErrors[path] || '';
}
// global rules active tab
let globalRulesTab = 'capture';
// helper for host tab - now uses reactive currentHostTab
function setHostActiveTab(hostIndex, tab) {
hostActiveTabs = { ...hostActiveTabs, [hostIndex]: tab };
}
// handle input changes - dispatch to parent on every keystroke
function handleNameInput(e) {
const value = e.target.value;
dispatch('nameChange', value);
}
function handleDescriptionInput(e) {
const value = e.target.value;
dispatch('descriptionChange', value);
}
function handleStartURLInput(e) {
const value = e.target.value;
dispatch('startURLChange', value);
}
// file input reference for import
let fileInput = null;
// export configuration to YAML file with metadata
function exportConfig() {
// build the config with _meta section
const output = {};
// add general section with proxy metadata
output._general = {};
if (name) {
output._general.name = name;
}
if (description) {
output._general.description = description;
}
if (startURL) {
output._general.start_url = startURL;
}
// add version
output.version = configData.version || '0.0';
// add proxy if set
if (configData.proxy) {
output.proxy = configData.proxy;
}
// build global section
const global = {};
if (configData.global.tls?.mode) {
global.tls = { mode: configData.global.tls.mode };
}
if (configData.global.access?.mode) {
global.access = { mode: configData.global.access.mode };
if (configData.global.access.on_deny) {
global.access.on_deny = configData.global.access.on_deny;
}
}
if (configData.global.impersonate?.enabled) {
global.impersonate = {
enabled: configData.global.impersonate.enabled
};
if (configData.global.impersonate.retain_ua) {
global.impersonate.retain_ua = configData.global.impersonate.retain_ua;
}
}
if (configData.global.variables?.enabled) {
global.variables = {
enabled: configData.global.variables.enabled
};
if (configData.global.variables.allowed?.length > 0) {
global.variables.allowed = configData.global.variables.allowed;
}
}
// filter and add global rules (only include touched/valid rules)
const globalCapture = (configData.global.capture || []).filter(isCaptureRuleTouched);
if (globalCapture.length > 0) {
global.capture = stripIds(globalCapture);
}
const globalRewrite = (configData.global.rewrite || []).filter(isRewriteRuleTouched);
if (globalRewrite.length > 0) {
global.rewrite = stripIds(globalRewrite);
}
const globalResponse = (configData.global.response || []).filter(isResponseRuleTouched);
if (globalResponse.length > 0) {
global.response = stripIds(globalResponse);
}
const globalRewriteUrls = (configData.global.rewrite_urls || []).filter(
isUrlRewriteRuleTouched
);
if (globalRewriteUrls.length > 0) {
global.rewrite_urls = stripIds(globalRewriteUrls);
}
if (Object.keys(global).length > 0) {
output.global = global;
}
// add hosts
for (const host of configData.hosts) {
if (!host.domain || !host.to) continue;
const hostObj = { to: host.to };
if (host.scheme && host.scheme !== 'https') {
hostObj.scheme = host.scheme;
}
if (host.tls?.mode) {
hostObj.tls = { mode: host.tls.mode };
}
if (host.access?.mode) {
hostObj.access = { mode: host.access.mode };
if (host.access.on_deny) {
hostObj.access.on_deny = host.access.on_deny;
}
}
// filter and add host rules (only include touched/valid rules)
const hostCapture = (host.capture || []).filter(isCaptureRuleTouched);
if (hostCapture.length > 0) {
hostObj.capture = stripIds(hostCapture);
}
const hostRewrite = (host.rewrite || []).filter(isRewriteRuleTouched);
if (hostRewrite.length > 0) {
hostObj.rewrite = stripIds(hostRewrite);
}
const hostResponse = (host.response || []).filter(isResponseRuleTouched);
if (hostResponse.length > 0) {
hostObj.response = stripIds(hostResponse);
}
const hostRewriteUrls = (host.rewrite_urls || []).filter(isUrlRewriteRuleTouched);
if (hostRewriteUrls.length > 0) {
hostObj.rewrite_urls = stripIds(hostRewriteUrls);
}
output[host.domain] = cleanObject(hostObj);
}
// serialize to YAML
const yamlContent = jsyaml.dump(output, {
indent: 2,
lineWidth: 120, // allow block scalars for multiline strings
quotingType: "'",
forceQuotes: false,
noRefs: true
});
// create blob and download
const blob = new Blob([yamlContent], { type: 'application/x-yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// sanitize filename
const safeName = (name || 'proxy-config').replace(/[^a-zA-Z0-9-_]/g, '_');
a.download = `${safeName}.yaml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// trigger file input for import
// handle file selection for import
function handleImportFile(event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result;
if (typeof content === 'string') {
importConfig(content);
}
};
reader.readAsText(file);
// reset file input so same file can be imported again
event.target.value = '';
}
// import configuration from YAML string with metadata
function importConfig(yamlStr) {
if (!yamlStr || yamlStr.trim() === '') {
return;
}
try {
const parsed = jsyaml.load(yamlStr);
if (!parsed || typeof parsed !== 'object') {
console.warn('Invalid YAML: not an object');
return;
}
// extract and apply general section
if (parsed._general) {
if (parsed._general.name) {
dispatch('nameChange', parsed._general.name);
}
if (parsed._general.description) {
dispatch('descriptionChange', parsed._general.description);
}
if (parsed._general.start_url) {
dispatch('startURLChange', parsed._general.start_url);
}
}
// parse the rest as normal config
configData.version = String(parsed.version || '0.0');
configData.proxy = parsed.proxy || '';
if (parsed.global) {
configData.global.tls = parsed.global.tls || { mode: 'managed' };
configData.global.access = parsed.global.access || { mode: 'private', on_deny: '' };
configData.global.impersonate = parsed.global.impersonate || {
enabled: false,
retain_ua: false
};
configData.global.variables = parsed.global.variables || {
enabled: false,
allowed: []
};
configData.global.capture = (parsed.global.capture || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.response = (parsed.global.response || []).map((r) => ({
...r,
_id: getRuleId()
}));
configData.global.rewrite_urls = (parsed.global.rewrite_urls || []).map((r) => ({
...r,
_id: getRuleId()
}));
} else {
// reset global if not present
configData.global = {
tls: { mode: 'managed' },
access: { mode: 'private', on_deny: '' },
impersonate: { enabled: false, retain_ua: false },
variables: { enabled: false, allowed: [] },
capture: [],
rewrite: [],
response: [],
rewrite_urls: []
};
}
// extract hosts (keys that are not reserved and have a 'to' property)
const hosts = [];
for (const key of Object.keys(parsed)) {
if (
key !== '_general' &&
key !== 'version' &&
key !== 'proxy' &&
key !== 'global' &&
parsed[key] &&
typeof parsed[key] === 'object' &&
parsed[key].to
) {
const hostData = parsed[key];
hosts.push({
domain: key,
to: hostData.to || '',
scheme: hostData.scheme || 'https',
tls: { mode: hostData.tls?.mode || '' },
access: {
mode: hostData.access?.mode || '',
on_deny: hostData.access?.on_deny || ''
},
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
});
}
}
configData.hosts = hosts;
// expand first host if exists
if (hosts.length > 0) {
expandedHostIndex = 0;
}
// trigger reactive update
configData = configData;
} catch (e) {
console.warn('Failed to parse imported YAML config:', e);
}
}
let wrapperElement;
function handleWrapperClick() {
// focus the wrapper so Ctrl+S works (FormGrid checks if activeElement is inside the form)
if (wrapperElement && document.activeElement === document.body) {
wrapperElement.focus();
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<div
class="proxy-builder-wrapper"
tabindex="-1"
role="region"
aria-label="Proxy configuration builder"
bind:this={wrapperElement}
on:click={handleWrapperClick}
>
<div class="proxy-builder">
<!-- main tabs -->
<div class="main-tabs">
<button
type="button"
class="main-tab"
class:active={activeTab === 'basic'}
on:click={() => (activeTab = 'basic')}
>
<svg
class="tab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
General
</button>
<button
type="button"
class="main-tab"
class:active={activeTab === 'global'}
on:click={() => (activeTab = 'global')}
>
<svg
class="tab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Global Settings
</button>
<button
type="button"
class="main-tab"
class:active={activeTab === 'hosts'}
on:click={() => (activeTab = 'hosts')}
>
<svg
class="tab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
Hosts
{#if configData.hosts.length > 0}
<span class="badge">{configData.hosts.length}</span>
{/if}
</button>
</div>
<!-- tab content -->
<div class="tab-content">
{#if activeTab === 'basic'}
<!-- basic information tab content -->
<div class="basic-panel">
<!-- basic information section -->
<!-- hidden file input for import -->
<input
type="file"
accept=".yaml,.yml"
bind:this={fileInput}
on:change={handleImportFile}
class="hidden"
/>
<div class="settings-section">
<div class="settings-section-header">
<h3 class="settings-section-title">General</h3>
</div>
<div class="settings-grid">
<div class="field-wrapper">
<label class="flex flex-col py-2">
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200"
>
Name
</p>
</div>
<input
type="text"
value={name}
on:input={handleNameInput}
placeholder="Company Auth Proxy"
required
minlength="1"
maxlength="64"
class="w-full text-ellipsis rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal transition-colors duration-200"
/>
</label>
</div>
<div class="field-wrapper">
<label class="flex flex-col py-2">
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200"
>
Description
</p>
</div>
<input
type="text"
value={description}
on:input={handleDescriptionInput}
placeholder="Optional description"
maxlength="255"
class="w-full text-ellipsis rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal transition-colors duration-200"
/>
</label>
</div>
<div class="field-wrapper full">
<label class="flex flex-col py-2">
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200"
>
Start URL
</p>
</div>
<input
type="text"
value={startURL}
on:input={handleStartURLInput}
placeholder="https://login.example.com/auth"
required
minlength="3"
class="w-full text-ellipsis rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal transition-colors duration-200"
/>
</label>
<span class="settings-field-hint"
>Domain must match a phishing domain in the Hosts tab</span
>
</div>
</div>
</div>
<!-- proxy configuration section -->
<div class="settings-section">
<h3 class="settings-section-title">Proxy Settings</h3>
<div class="settings-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={configData.proxy}
placeholder="socks5://proxy.example.com:1080 (optional)"
>
Forward Proxy
</TextField>
<span class="settings-field-hint">Route all traffic through this proxy</span>
</div>
</div>
</div>
</div>
{:else if activeTab === 'hosts'}
<div class="hosts-panel">
<!-- hosts list sidebar -->
<div class="hosts-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Domain Mappings</span>
<button type="button" class="add-btn small" on:click={addHost} title="Add Host">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{#if configData.hosts.length > 3}
<div class="px-2 pb-2 host-search-wrapper">
<Search pagination={hostSearchPagination} />
</div>
{/if}
<div class="flex-1 overflow-y-auto p-2">
{#if configData.hosts.length > 0}
<div class="flex flex-col gap-1.5">
{#each filteredHosts as { host, index: i }}
<button
type="button"
class="flex flex-col gap-2 w-full p-3 text-left bg-white dark:bg-slate-800/40 border rounded-lg cursor-pointer transition-all duration-150
{expandedHostIndex === i
? 'border-sky-600 dark:border-sky-400 bg-sky-50 dark:bg-sky-900/20 shadow-sm ring-1 ring-sky-600/20 dark:ring-sky-400/20'
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30'}"
on:click={() => (expandedHostIndex = i)}
>
<div class="flex flex-col gap-0.5 min-w-0">
<span
class="text-[0.625rem] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider"
>From</span
>
<span
class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"
title={host.to || 'New Host'}>{host.to || 'New Host'}</span
>
</div>
<div class="flex flex-col gap-0.5 min-w-0">
<span
class="text-[0.625rem] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider"
>To</span
>
<span
class="text-sm text-gray-600 dark:text-gray-300 truncate"
title={host.domain || '...'}>{host.domain || '...'}</span
>
</div>
{#if host.capture?.length || host.rewrite?.length || host.response?.length}
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2 border-t border-gray-100 dark:border-gray-700/40 text-[0.6875rem]"
>
{#if host.capture?.length}
<span
class="flex items-center gap-1 text-green-600 dark:text-green-400"
>
<span class="w-1.5 h-1.5 rounded-full bg-green-500 dark:bg-green-400"
></span>
{host.capture.length} capture
</span>
{/if}
{#if host.rewrite?.length}
<span class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 dark:bg-blue-400"
></span>
{host.rewrite.length} rewrite
</span>
{/if}
{#if host.response?.length}
<span
class="flex items-center gap-1 text-amber-600 dark:text-amber-400"
>
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 dark:bg-amber-400"
></span>
{host.response.length} response
</span>
{/if}
</div>
{/if}
</button>
{/each}
</div>
{:else}
<div class="empty-state small">
<p>No hosts configured</p>
<button type="button" class="add-btn" on:click={addHost}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add First Host
</button>
</div>
{/if}
</div>
</div>
<!-- host detail panel -->
<div class="host-detail">
{#if expandedHostIndex >= 0 && configData.hosts[expandedHostIndex]}
<div class="detail-header">
<div class="detail-title">
<span class="domain-label"
>{configData.hosts[expandedHostIndex].to || 'New Host'}</span
>
<span class="arrow"></span>
<span class="target-label"
>{configData.hosts[expandedHostIndex].domain || 'target'}</span
>
</div>
<div class="detail-actions">
<button
type="button"
class="icon-btn"
title="Duplicate"
on:click={() => duplicateHost(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
<button
type="button"
class="icon-btn danger"
title="Delete"
on:click={() => removeHost(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- sub tabs -->
<div class="sub-tabs">
<button
type="button"
class="sub-tab"
class:active={currentHostTab === 'settings'}
on:click={() => setHostActiveTab(expandedHostIndex, 'settings')}
>
Settings
</button>
<button
type="button"
class="sub-tab"
class:active={currentHostTab === 'capture'}
on:click={() => setHostActiveTab(expandedHostIndex, 'capture')}
>
Capture
{#if configData.hosts[expandedHostIndex].capture?.length}
<span class="sub-badge"
>{configData.hosts[expandedHostIndex].capture.length}</span
>
{/if}
</button>
<button
type="button"
class="sub-tab"
class:active={currentHostTab === 'rewrite'}
on:click={() => setHostActiveTab(expandedHostIndex, 'rewrite')}
>
Rewrite
{#if configData.hosts[expandedHostIndex].rewrite?.length}
<span class="sub-badge"
>{configData.hosts[expandedHostIndex].rewrite.length}</span
>
{/if}
</button>
<button
type="button"
class="sub-tab"
class:active={currentHostTab === 'response'}
on:click={() => setHostActiveTab(expandedHostIndex, 'response')}
>
Response
{#if configData.hosts[expandedHostIndex].response?.length}
<span class="sub-badge"
>{configData.hosts[expandedHostIndex].response.length}</span
>
{/if}
</button>
<button
type="button"
class="sub-tab"
class:active={currentHostTab === 'urlrewrite'}
on:click={() => setHostActiveTab(expandedHostIndex, 'urlrewrite')}
>
URL Rewrite
{#if configData.hosts[expandedHostIndex].rewrite_urls?.length}
<span class="sub-badge"
>{configData.hosts[expandedHostIndex].rewrite_urls.length}</span
>
{/if}
</button>
</div>
<div class="sub-content">
{#if currentHostTab === 'settings'}
<div class="settings-grid host-settings">
<div class="field-wrapper full">
<TextField
width="full"
bind:value={configData.hosts[expandedHostIndex].to}
placeholder="login.phish.test"
required
error={hasError(`hosts.${expandedHostIndex}.to`)}
>
Phishing Domain
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.to`)}
<span class="field-error">{getError(`hosts.${expandedHostIndex}.to`)}</span>
{:else}
<span class="form-hint"
>Your phishing domain that will serve the content</span
>
{/if}
</div>
<div class="field-wrapper full">
<TextField
width="full"
bind:value={configData.hosts[expandedHostIndex].domain}
placeholder="login.target.com"
required
error={hasError(`hosts.${expandedHostIndex}.domain`)}
>
Target Domain
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.domain`)}
<span class="field-error"
>{getError(`hosts.${expandedHostIndex}.domain`)}</span
>
{:else}
<span class="form-hint">The legitimate domain being impersonated</span>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-scheme`}
bind:value={configData.hosts[expandedHostIndex].scheme}
options={schemes}
size="normal"
>
Scheme
</TextFieldSelect>
</div>
{#if configData.hosts[expandedHostIndex].tls}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-tls-mode`}
bind:value={configData.hosts[expandedHostIndex].tls.mode}
options={tlsModesWithEmpty}
size="normal"
optional
>
TLS Mode
</TextFieldSelect>
</div>
{/if}
{#if configData.hosts[expandedHostIndex].access}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-access-mode`}
bind:value={configData.hosts[expandedHostIndex].access.mode}
options={accessModesWithEmpty}
size="normal"
optional
>
Access Mode
</TextFieldSelect>
<span class="form-hint"
>Private requires visiting a lure URL first (recommended)</span
>
</div>
{#if configData.hosts[expandedHostIndex].access?.mode === 'private'}
<div class="field-wrapper">
<TextField
width="full"
bind:value={configData.hosts[expandedHostIndex].access.on_deny}
placeholder="404"
>
On Deny
</TextField>
<span class="form-hint"
>Status code (e.g. 404, 503) or redirect URL (e.g. https://example.com)</span
>
</div>
{/if}
{/if}
</div>
{:else if currentHostTab === 'capture'}
<div class="rules-description">
<p>Extract credentials, tokens, and other data from requests and responses.</p>
</div>
<div class="rules-container">
{#each configData.hosts[expandedHostIndex].capture || [] as rule, ruleIndex (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.name || `Rule ${ruleIndex + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeHostCaptureRule(expandedHostIndex, ruleIndex)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.name}
placeholder="username"
error={hasError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.name`
)}
>
Name
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.capture.${ruleIndex}.name`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.name`
)}</span
>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-capture-${ruleIndex}-method`}
bind:value={rule.method}
options={methods}
size="normal"
>
Method
</TextFieldSelect>
</div>
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.path}
placeholder="/login"
error={hasError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.path`
)}
>
Path (regex)
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.capture.${ruleIndex}.path`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.path`
)}</span
>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-capture-${ruleIndex}-engine`}
value={rule.engine}
options={captureEngines}
size="normal"
onSelect={(val) => handleCaptureEngineChange(rule, val)}
>
Engine
</TextFieldSelect>
</div>
{#if rule.engine !== 'cookie'}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-capture-${ruleIndex}-from`}
bind:value={rule.from}
options={getFromOptionsForEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'regex'
? 'username=([^&]+)'
: rule.engine === 'header'
? 'Authorization'
: rule.engine === 'cookie'
? 'session_id'
: rule.engine === 'json'
? 'user.email'
: 'username'}
error={hasError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.find`
)}
>
{#if rule.engine === 'regex'}
Regex Pattern
{:else if rule.engine === 'header'}
Header Name
{:else if rule.engine === 'cookie'}
Cookie Name
{:else if rule.engine === 'json'}
JSON Path
{:else}
Field Name
{/if}
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.capture.${ruleIndex}.find`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.capture.${ruleIndex}.find`
)}</span
>
{/if}
</div>
<div class="field-wrapper checkbox-wrapper">
<label class="checkbox-label">
<input
type="checkbox"
checked={rule.required}
on:change={(e) => {
rule.required = e.currentTarget.checked;
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Required</span>
</label>
<span class="form-hint"
>Must be captured before session completes and campaign flow
progresses</span
>
</div>
</div>
</div>
{/each}
<button
type="button"
class="add-rule-btn"
on:click={() => addHostCaptureRule(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Capture Rule
</button>
</div>
{:else if currentHostTab === 'rewrite'}
<div class="rules-description">
<p>
Rewrite rules modify content passing through the proxy. Use <strong
>Regex</strong
>
for text replacement or <strong>DOM</strong> for HTML element manipulation.
</p>
</div>
<div class="rules-container">
{#each configData.hosts[expandedHostIndex].rewrite || [] as rule, ruleIndex (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.name || `Rule ${ruleIndex + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeHostRewriteRule(expandedHostIndex, ruleIndex)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.name}
placeholder="replace_logo"
>
Name
</TextField>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-engine`}
bind:value={rule.engine}
options={engines}
size="normal"
>
Engine
</TextFieldSelect>
</div>
{#if rule.engine === 'dom'}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-action`}
bind:value={rule.action}
options={domActions}
size="normal"
>
Action
</TextFieldSelect>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.action`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.action`
)}</span
>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-target`}
bind:value={rule.target}
options={targets}
size="normal"
>
Target
</TextFieldSelect>
<span class="form-hint"
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
bind:value={rule.from}
options={fromOptions}
size="normal"
>
From
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
error={hasError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`
)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else}
Find (regex)
{/if}
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`
)}</span
>
{:else}
<span class="form-hint">
{#if rule.engine === 'dom'}
CSS selector to find HTML elements
{:else}
Regex pattern to search for in content
{/if}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
)}</span
>
{:else}
<span class="form-hint">
{#if rule.engine === 'dom'}
{#if rule.action === 'setAttr'}
Format: attribute:value (e.g. href:https://example.com)
{:else if rule.action === 'remove'}
Not required for remove action
{:else if rule.action === 'removeAttr'}
Attribute name to remove
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
CSS class name
{:else}
New content for matched elements
{/if}
{:else}
Replacement text (use $1, $2 for capture groups)
{/if}
</span>
{/if}
</div>
</div>
</div>
{/each}
<button
type="button"
class="add-rule-btn"
on:click={() => addHostRewriteRule(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Rewrite Rule
</button>
</div>
{:else if currentHostTab === 'response'}
<div class="rules-description">
<p>
Return custom responses for specific paths instead of proxying to the target.
</p>
</div>
<div class="rules-container">
{#each configData.hosts[expandedHostIndex].response || [] as rule, ruleIndex (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.path || `Rule ${ruleIndex + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeHostResponseRule(expandedHostIndex, ruleIndex)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.path}
placeholder="/custom-page"
error={hasError(
`hosts.${expandedHostIndex}.response.${ruleIndex}.path`
)}
>
Path
</TextField>
{#if hasError(`hosts.${expandedHostIndex}.response.${ruleIndex}.path`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.response.${ruleIndex}.path`
)}</span
>
{/if}
</div>
<div class="field-wrapper">
<TextField width="full" bind:value={rule.status} placeholder="200">
Status
</TextField>
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.body}
placeholder="<html>...</html>"
height="medium"
>
Body
</TextareaField>
</div>
<div class="field-wrapper full">
<div class="headers-section">
<div class="headers-label">
<span>Headers</span>
<button
type="button"
class="add-btn tiny"
on:click={() => addResponseHeader(rule)}
title="Add Header"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{#if rule.headers && Object.keys(rule.headers).length > 0}
<div class="headers-list">
{#each Object.entries(rule.headers) as [key, value]}
<div class="header-row">
<input
type="text"
value={key}
on:blur={(e) =>
updateResponseHeaderKey(rule, key, e.currentTarget.value)}
placeholder="Header-Name"
class="header-key-input"
/>
<input
type="text"
bind:value={rule.headers[key]}
placeholder="Header value"
class="header-value-input"
/>
<button
type="button"
class="icon-btn tiny danger"
on:click={() => removeResponseHeader(rule, key)}
title="Remove header"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<span class="form-hint"
>Use <code>{'{{.Origin}}'}</code> to echo the request's Origin header</span
>
</div>
</div>
<div class="field-wrapper checkbox-wrapper">
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={rule.forward}
class="checkbox-input"
on:change={(e) => {
rule.forward = e.currentTarget.checked;
configData = configData;
}}
/>
<span class="checkbox-text">Forward to target</span>
</label>
</div>
</div>
</div>
{/each}
<button
type="button"
class="add-rule-btn"
on:click={() => addHostResponseRule(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Response Rule
</button>
</div>
{:else if currentHostTab === 'urlrewrite'}
<div class="rules-description">
<p>Transform URL paths to evade detection by masking original target URLs.</p>
</div>
<div class="rules-container">
{#each configData.hosts[expandedHostIndex].rewrite_urls || [] as rule, ruleIndex (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.find || `Rule ${ruleIndex + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeHostRewriteUrlRule(expandedHostIndex, ruleIndex)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField width="full" bind:value={rule.find} placeholder="/old-path">
Find
</TextField>
</div>
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.replace}
placeholder="/new-path"
>
Replace
</TextField>
</div>
</div>
</div>
{/each}
<button
type="button"
class="add-rule-btn"
on:click={() => addHostRewriteUrlRule(expandedHostIndex)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add URL Rewrite Rule
</button>
</div>
{/if}
</div>
{:else}
<div class="empty-state">
<svg
class="empty-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
<p>Select a host or add a new one</p>
<button type="button" class="add-btn" on:click={addHost}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Host
</button>
</div>
{/if}
</div>
</div>
{:else if activeTab === 'global'}
<div class="global-panel">
<div class="global-grid">
<!-- TLS & Access -->
<div class="global-section">
<h3 class="section-title">
<svg
class="section-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Security
</h3>
<div class="section-content">
<div class="field-wrapper">
<TextFieldSelect
id="global-tls-mode"
bind:value={configData.global.tls.mode}
options={tlsModes}
size="normal"
>
TLS Mode
</TextFieldSelect>
<span class="form-hint"
>Controls certificate verification for upstream connections</span
>
</div>
<div class="field-wrapper">
<TextFieldSelect
id="global-access-mode"
bind:value={configData.global.access.mode}
options={accessModes}
size="normal"
>
Access Mode
</TextFieldSelect>
<span class="form-hint"
>Private requires visiting a lure URL first (recommended)</span
>
</div>
{#if configData.global.access?.mode === 'private'}
<div class="field-wrapper">
<TextField
width="full"
bind:value={configData.global.access.on_deny}
placeholder="404"
>
On Deny
</TextField>
<span class="form-hint"
>Status code (e.g. 404, 503) or redirect URL (e.g. https://example.com)</span
>
</div>
{/if}
</div>
</div>
<!-- Impersonation -->
<div class="global-section">
<h3 class="section-title">
<svg
class="section-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Client Browser Impersonation
</h3>
<div class="section-content">
<label class="checkbox-label">
<input
type="checkbox"
checked={configData.global.impersonate.enabled}
on:change={(e) => {
configData.global.impersonate.enabled = e.currentTarget.checked;
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Enable Impersonation</span>
</label>
<span class="form-hint"
>Detects client browser and uses a matching fingerprint profile (Chrome or Firefox
only, others default to Chrome)</span
>
{#if configData.global.impersonate.enabled}
<label class="checkbox-label" style="margin-top: 0.5rem;">
<input
type="checkbox"
checked={configData.global.impersonate.retain_ua}
on:change={(e) => {
configData.global.impersonate.retain_ua = e.currentTarget.checked;
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Retain User Agent</span>
</label>
<span class="form-hint"
>Use the client's User-Agent header instead of the impersonated browser's
default</span
>
{/if}
</div>
</div>
<!-- Template Variables -->
<div class="global-section">
<h3 class="section-title">
<svg
class="section-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
Template Variables
</h3>
<div class="section-content">
<label class="checkbox-label">
<input
type="checkbox"
checked={configData.global.variables.enabled}
on:change={(e) => {
configData.global.variables.enabled = e.currentTarget.checked;
if (!e.currentTarget.checked) {
configData.global.variables.allowed = [];
}
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Enable Variables</span>
</label>
<span class="form-hint"
>Allow template variables like <code>{'{{.Email}}'}</code> in rewrite rules to be replaced
with recipient data</span
>
{#if configData.global.variables.enabled}
<div class="field-wrapper" style="margin-top: 0.75rem;">
<label class="flex flex-col">
<span class="text-sm font-medium text-pc-darkblue dark:text-white mb-1.5"
>Allowed Variables (optional)</span
>
<div class="variables-selector">
{#each validProxyVariables as varName}
<label class="variable-chip">
<input
type="checkbox"
checked={configData.global.variables.allowed?.includes(varName)}
on:change={(e) => {
if (e.currentTarget.checked) {
configData.global.variables.allowed = [
...(configData.global.variables.allowed || []),
varName
];
} else {
configData.global.variables.allowed =
configData.global.variables.allowed?.filter(
(v) => v !== varName
) || [];
}
configData = configData;
}}
class="hidden"
/>
<span
class="chip-text"
class:selected={configData.global.variables.allowed?.includes(
varName
)}>{varName}</span
>
</label>
{/each}
</div>
</label>
<span class="form-hint"
>Leave empty to allow all variables, or select specific ones to restrict which
can be used</span
>
</div>
{/if}
</div>
</div>
</div>
<!-- Global Rules Tabs -->
<div class="global-rules">
<div class="rules-tabs">
<button
type="button"
class="rules-tab"
class:active={(activeTab === 'global' && !globalRulesTab) ||
globalRulesTab === 'capture'}
on:click={() => (globalRulesTab = 'capture')}
>
Capture Rules
{#if configData.global.capture?.length}
<span class="sub-badge">{configData.global.capture.length}</span>
{/if}
</button>
<button
type="button"
class="rules-tab"
class:active={globalRulesTab === 'rewrite'}
on:click={() => (globalRulesTab = 'rewrite')}
>
Rewrite Rules
{#if configData.global.rewrite?.length}
<span class="sub-badge">{configData.global.rewrite.length}</span>
{/if}
</button>
<button
type="button"
class="rules-tab"
class:active={globalRulesTab === 'response'}
on:click={() => (globalRulesTab = 'response')}
>
Response Rules
{#if configData.global.response?.length}
<span class="sub-badge">{configData.global.response.length}</span>
{/if}
</button>
</div>
<div class="rules-content">
{#if !globalRulesTab || globalRulesTab === 'capture'}
<div class="rules-description">
<p>Extract credentials, tokens, and other data from requests and responses.</p>
</div>
<div class="rules-container">
{#each configData.global.capture || [] as rule, i (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.name || `Rule ${i + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeGlobalCaptureRule(i)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.name}
placeholder="username"
error={hasError(`global.capture.${i}.name`)}
>
Name
</TextField>
{#if hasError(`global.capture.${i}.name`)}
<span class="field-error">{getError(`global.capture.${i}.name`)}</span>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-capture-${i}-method`}
bind:value={rule.method}
options={methods}
size="normal"
>
Method
</TextFieldSelect>
</div>
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.path}
placeholder="/login"
error={hasError(`global.capture.${i}.path`)}
>
Path (regex)
</TextField>
{#if hasError(`global.capture.${i}.path`)}
<span class="field-error">{getError(`global.capture.${i}.path`)}</span>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-capture-${i}-engine`}
value={rule.engine}
options={captureEngines}
size="normal"
onSelect={(val) => handleCaptureEngineChange(rule, val)}
>
Engine
</TextFieldSelect>
</div>
{#if rule.engine !== 'cookie'}
<div class="field-wrapper">
<TextFieldSelect
id={`global-capture-${i}-from`}
bind:value={rule.from}
options={getFromOptionsForEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'regex'
? 'username=([^&]+)'
: rule.engine === 'header'
? 'Authorization'
: rule.engine === 'cookie'
? 'session_id'
: rule.engine === 'json'
? 'user.email'
: 'username'}
error={hasError(`global.capture.${i}.find`)}
>
{#if rule.engine === 'regex'}
Regex Pattern
{:else if rule.engine === 'header'}
Header Name
{:else if rule.engine === 'cookie'}
Cookie Name
{:else if rule.engine === 'json'}
JSON Path
{:else}
Field Name
{/if}
</TextField>
{#if hasError(`global.capture.${i}.find`)}
<span class="field-error">{getError(`global.capture.${i}.find`)}</span>
{/if}
</div>
<div class="field-wrapper checkbox-wrapper">
<label class="checkbox-label">
<input
type="checkbox"
checked={rule.required}
on:change={(e) => {
rule.required = e.currentTarget.checked;
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Required</span>
</label>
<span class="form-hint"
>Must be captured before session completes and campaign flow progresses</span
>
</div>
</div>
</div>
{/each}
<button type="button" class="add-rule-btn" on:click={addGlobalCaptureRule}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Capture Rule
</button>
</div>
{:else if globalRulesTab === 'rewrite'}
<div class="rules-description">
<p>
Rewrite rules modify content passing through the proxy. Use <strong
>Regex</strong
>
for text replacement or <strong>DOM</strong> for HTML element manipulation.
</p>
</div>
<div class="rules-container">
{#each configData.global.rewrite || [] as rule, i (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.name || `Rule ${i + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeGlobalRewriteRule(i)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField width="full" bind:value={rule.name} placeholder="replace_logo">
Name
</TextField>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-engine`}
bind:value={rule.engine}
options={engines}
size="normal"
>
Engine
</TextFieldSelect>
</div>
{#if rule.engine === 'dom'}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-action`}
bind:value={rule.action}
options={domActions}
size="normal"
>
Action
</TextFieldSelect>
{#if hasError(`global.rewrite.${i}.action`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.action`)}</span
>
{/if}
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-target`}
bind:value={rule.target}
options={targets}
size="normal"
>
Target
</TextFieldSelect>
<span class="form-hint"
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-from`}
bind:value={rule.from}
options={fromOptions}
size="normal"
>
From
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
error={hasError(`global.rewrite.${i}.find`)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else}
Find (regex)
{/if}
</TextField>
{#if hasError(`global.rewrite.${i}.find`)}
<span class="field-error">{getError(`global.rewrite.${i}.find`)}</span>
{:else}
<span class="form-hint">
{#if rule.engine === 'dom'}
CSS selector to find HTML elements
{:else}
Regex pattern to search for in content
{/if}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`global.rewrite.${i}.replace`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.replace`)}</span
>
{:else}
<span class="form-hint">
{#if rule.engine === 'dom'}
{#if rule.action === 'setAttr'}
Format: attribute:value (e.g. href:https://example.com)
{:else if rule.action === 'remove'}
Not required for remove action
{:else if rule.action === 'removeAttr'}
Attribute name to remove
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
CSS class name
{:else}
New content for matched elements
{/if}
{:else}
Replacement text (use $1, $2 for capture groups)
{/if}
</span>
{/if}
</div>
</div>
</div>
{/each}
<button type="button" class="add-rule-btn" on:click={addGlobalRewriteRule}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Rewrite Rule
</button>
</div>
{:else if globalRulesTab === 'response'}
<div class="rules-description">
<p>
Return custom responses for specific paths instead of proxying to the target.
</p>
</div>
<div class="rules-container">
{#each configData.global.response || [] as rule, i (rule._id)}
<div class="rule-card">
<div class="rule-header">
<span class="rule-name">{rule.path || `Rule ${i + 1}`}</span>
<button
type="button"
class="icon-btn small danger"
on:click={() => removeGlobalResponseRule(i)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="rule-grid">
<div class="field-wrapper">
<TextField
width="full"
bind:value={rule.path}
placeholder="/custom-page"
error={hasError(`global.response.${i}.path`)}
>
Path
</TextField>
{#if hasError(`global.response.${i}.path`)}
<span class="field-error">{getError(`global.response.${i}.path`)}</span>
{/if}
</div>
<div class="field-wrapper">
<TextField width="full" bind:value={rule.status} placeholder="200">
Status
</TextField>
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.body}
placeholder="<html>...</html>"
height="medium"
>
Body
</TextareaField>
</div>
<div class="field-wrapper full">
<div class="headers-section">
<div class="headers-label">
<span>Headers</span>
<button
type="button"
class="add-btn tiny"
on:click={() => addResponseHeader(rule)}
title="Add Header"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{#if rule.headers && Object.keys(rule.headers).length > 0}
<div class="headers-list">
{#each Object.entries(rule.headers) as [key, value]}
<div class="header-row">
<input
type="text"
value={key}
on:blur={(e) =>
updateResponseHeaderKey(rule, key, e.currentTarget.value)}
placeholder="Header-Name"
class="header-key-input"
/>
<input
type="text"
bind:value={rule.headers[key]}
placeholder="Header value"
class="header-value-input"
/>
<button
type="button"
class="icon-btn tiny danger"
on:click={() => removeResponseHeader(rule, key)}
title="Remove header"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<span class="form-hint"
>Use <code>{'{{.Origin}}'}</code> to echo the request's Origin header</span
>
</div>
</div>
<div class="field-wrapper checkbox-wrapper">
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={rule.forward}
on:change={(e) => {
rule.forward = e.currentTarget.checked;
configData = configData;
}}
class="checkbox-input"
/>
<span class="checkbox-text">Forward to target</span>
</label>
</div>
</div>
</div>
{/each}
<button type="button" class="add-rule-btn" on:click={addGlobalResponseRule}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4" />
</svg>
Add Response Rule
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
/* wrapper */
.proxy-builder-wrapper {
width: 100%;
height: 100%;
min-height: 500px;
overflow: auto;
}
.proxy-builder {
display: flex;
flex-direction: column;
height: 100%;
background: white;
border-radius: 0.5rem;
}
:global(.dark) .proxy-builder {
background: rgb(17 24 39 / 0.6);
}
/* settings section */
.settings-section {
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.settings-section:last-child {
border-bottom: none;
}
:global(.dark) .settings-section {
border-bottom-color: rgb(55 65 81 / 0.6);
}
.settings-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.settings-section-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(71, 85, 105);
margin-bottom: 0;
}
.settings-section-header + .settings-grid {
margin-top: 0;
}
.hidden {
display: none;
}
:global(.dark) .settings-section-title {
color: #9ca3af;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem 1.5rem;
}
.field-wrapper {
display: flex;
flex-direction: column;
}
.field-wrapper.full {
grid-column: span 2;
}
.field-wrapper.checkbox-wrapper {
padding-top: 0.5rem;
}
.settings-field-hint,
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
padding-left: 0.125rem;
}
:global(.dark) .settings-field-hint,
:global(.dark) .form-hint {
color: #9ca3af;
}
.rules-description {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
}
:global(.dark) .rules-description {
background: rgba(30, 41, 59, 0.5);
border-color: #334155;
}
.rules-description p {
font-size: 0.875rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
:global(.dark) .rules-description p {
color: #94a3b8;
}
.rules-description strong {
color: #475569;
font-weight: 600;
}
:global(.dark) .rules-description strong {
color: #cbd5e1;
}
/* main tabs */
.main-tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
border-radius: 0.5rem 0.5rem 0 0;
}
:global(.dark) .main-tabs {
background: rgb(31 41 55 / 0.6);
border-bottom-color: rgb(55 65 81 / 0.6);
}
.main-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.main-tab:hover {
background: #e2e8f0;
color: #475569;
}
:global(.dark) .main-tab:hover {
background: rgb(55 65 81 / 0.6);
color: #d1d5db;
}
.main-tab.active {
background: white;
color: #0284c7;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .main-tab.active {
background: rgb(17 24 39 / 0.8);
color: #38bdf8;
}
.tab-icon {
width: 1.125rem;
height: 1.125rem;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 600;
background: #0284c7;
color: white;
border-radius: 9999px;
}
.main-tab:not(.active) .badge {
background: #94a3b8;
}
:global(.dark) .main-tab:not(.active) .badge {
background: rgb(75 85 99 / 0.8);
}
/* tab content */
.tab-content {
flex: 1;
overflow: auto;
}
/* basic panel */
.basic-panel {
height: 100%;
overflow-y: auto;
}
/* hosts panel */
.hosts-panel {
display: grid;
grid-template-columns: 280px 1fr;
height: 100%;
}
.hosts-sidebar {
border-right: 1px solid #e5e7eb;
background: #f8fafc;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .hosts-sidebar {
background: rgb(31 41 55 / 0.4);
border-right-color: rgb(55 65 81 / 0.6);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
:global(.dark) .sidebar-header {
border-bottom-color: rgb(55 65 81 / 0.6);
}
.sidebar-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
:global(.dark) .sidebar-title {
color: #9ca3af;
}
.host-search-wrapper :global(input) {
width: 100% !important;
}
.host-search-wrapper :global(> div) {
width: 100%;
}
/* host detail */
.host-detail {
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
:global(.dark) .detail-header {
background: rgb(31 41 55 / 0.4);
border-bottom-color: rgb(55 65 81 / 0.6);
}
.detail-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 500;
}
.domain-label {
color: #0284c7;
}
.arrow {
color: #94a3b8;
}
.target-label {
color: #64748b;
}
:global(.dark) .target-label {
color: #9ca3af;
}
.detail-actions {
display: flex;
gap: 0.5rem;
}
/* sub tabs */
.sub-tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: white;
border-bottom: 1px solid #e5e7eb;
}
:global(.dark) .sub-tabs {
background: rgb(17 24 39 / 0.6);
border-bottom-color: rgb(55 65 81 / 0.6);
}
.sub-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
color: #64748b;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.sub-tab:hover {
background: #f1f5f9;
color: #475569;
}
:global(.dark) .sub-tab:hover {
background: rgb(55 65 81 / 0.4);
color: #d1d5db;
}
.sub-tab.active {
background: #0284c7;
color: white;
}
:global(.dark) .sub-tab.active {
background: #0369a1;
}
.sub-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.25rem;
font-size: 0.625rem;
font-weight: 600;
background: #e2e8f0;
color: #475569;
border-radius: 9999px;
}
:global(.dark) .sub-badge {
background: rgb(55 65 81 / 0.6);
color: #d1d5db;
}
.sub-tab.active .sub-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.sub-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* host settings grid */
.host-settings {
max-width: 600px;
}
/* rules container */
.rules-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.rule-card {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:global(.dark) .rule-card {
background: rgb(31 41 55 / 0.4);
border-color: rgb(55 65 81 / 0.6);
}
.rule-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: white;
border-bottom: 1px solid #e5e7eb;
border-radius: 0.5rem 0.5rem 0 0;
}
:global(.dark) .rule-header {
background: rgb(17 24 39 / 0.4);
border-bottom-color: rgb(55 65 81 / 0.6);
}
.rule-name {
font-size: 0.875rem;
font-weight: 500;
color: #1e293b;
}
:global(.dark) .rule-name {
color: #f1f5f9;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25rem 1rem;
padding: 1rem;
}
/* buttons */
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background: #0284c7;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s ease;
}
.add-btn:hover {
background: #0369a1;
}
.add-btn.small {
padding: 0.375rem 0.5rem;
}
.add-btn svg {
width: 1rem;
height: 1rem;
}
.add-rule-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
background: transparent;
border: 2px dashed #e2e8f0;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
}
:global(.dark) .add-rule-btn {
border-color: rgb(55 65 81 / 0.6);
color: #9ca3af;
}
.add-rule-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
color: #475569;
}
:global(.dark) .add-rule-btn:hover {
background: rgb(55 65 81 / 0.4);
}
.add-rule-btn svg {
width: 1rem;
height: 1rem;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
background: transparent;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
color: #64748b;
cursor: pointer;
transition: all 0.15s ease;
}
:global(.dark) .icon-btn {
border-color: rgb(55 65 81 / 0.6);
color: #9ca3af;
}
.icon-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
:global(.dark) .icon-btn:hover {
background: rgb(55 65 81 / 0.4);
}
.icon-btn.danger:hover {
background: #fef2f2;
border-color: #fca5a5;
color: #dc2626;
}
:global(.dark) .icon-btn.danger:hover {
background: rgb(127 29 29 / 0.3);
border-color: #f87171;
color: #f87171;
}
.icon-btn.small {
width: 1.5rem;
height: 1.5rem;
}
.icon-btn svg {
width: 1rem;
height: 1rem;
}
/* empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.empty-state.small {
padding: 1.5rem;
}
.empty-state p {
color: #64748b;
margin-bottom: 1rem;
}
:global(.dark) .empty-state p {
color: #9ca3af;
}
.empty-icon {
width: 3rem;
height: 3rem;
color: #cbd5e1;
margin-bottom: 1rem;
}
:global(.dark) .empty-icon {
color: #4b5563;
}
/* global panel */
.global-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
}
.global-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.global-section {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
:global(.dark) .global-section {
background: rgb(31 41 55 / 0.4);
border-color: rgb(55 65 81 / 0.6);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #475569;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
:global(.dark) .section-title {
color: #d1d5db;
border-bottom-color: rgb(55 65 81 / 0.6);
}
.section-icon {
width: 1.125rem;
height: 1.125rem;
color: #0284c7;
}
:global(.dark) .section-icon {
color: #38bdf8;
}
.section-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* global rules */
.global-rules {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
}
:global(.dark) .global-rules {
background: rgb(31 41 55 / 0.4);
border-color: rgb(55 65 81 / 0.6);
}
.rules-tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem;
background: white;
border-bottom: 1px solid #e5e7eb;
}
:global(.dark) .rules-tabs {
background: rgb(17 24 39 / 0.4);
border-bottom-color: rgb(55 65 81 / 0.6);
}
.rules-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
color: #64748b;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.rules-tab:hover {
background: #f1f5f9;
color: #475569;
}
:global(.dark) .rules-tab:hover {
background: rgb(55 65 81 / 0.4);
color: #d1d5db;
}
.rules-tab.active {
background: #0284c7;
color: white;
}
:global(.dark) .rules-tab.active {
background: #0369a1;
}
.rules-content {
padding: 1rem;
}
/* checkbox styling */
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 0;
}
.checkbox-input {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #cbd5e1;
border-radius: 0.25rem;
background: #f8fafc;
cursor: pointer;
accent-color: #0284c7;
}
:global(.dark) .checkbox-input {
border-color: rgb(55 65 81 / 0.6);
background: rgb(17 24 39 / 0.6);
}
.checkbox-input:checked {
background: #0284c7;
border-color: #0284c7;
}
:global(.dark) .checkbox-input:checked {
background: #0369a1;
border-color: #0369a1;
}
.checkbox-input:focus {
outline: none;
border-color: #94a3b8;
}
:global(.dark) .checkbox-input:focus {
border-color: #38bdf8;
}
.checkbox-text {
font-size: 0.875rem;
font-weight: 500;
color: #475569;
}
:global(.dark) .checkbox-text {
color: #9ca3af;
}
/* field error styles */
.field-error {
display: block;
font-size: 0.75rem;
color: #dc2626;
margin-top: 0.25rem;
}
:global(.dark) .field-error {
color: #f87171;
}
/* headers section styles */
.headers-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.headers-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #475569;
}
:global(.dark) .headers-label {
color: #9ca3af;
}
.headers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.header-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.header-key-input,
.header-value-input {
flex: 1;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: #f1f5f9;
color: #475569;
transition: all 0.2s;
}
:global(.dark) .header-key-input,
:global(.dark) .header-value-input {
background: rgba(17, 24, 39, 0.6);
border-color: rgba(55, 65, 81, 0.6);
color: #d1d5db;
}
.header-key-input:focus,
.header-value-input:focus {
outline: none;
border-color: #94a3b8;
background: #f8fafc;
}
:global(.dark) .header-key-input:focus,
:global(.dark) .header-value-input:focus {
border-color: rgba(56, 189, 248, 0.5);
background: rgba(55, 65, 81, 0.6);
}
.header-key-input {
max-width: 180px;
}
.add-btn.tiny {
width: 1.25rem;
height: 1.25rem;
padding: 0.125rem;
}
.icon-btn.tiny {
width: 1.25rem;
height: 1.25rem;
padding: 0.125rem;
}
/* Variables selector styles */
.variables-selector {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
}
:global(.dark) .variables-selector {
background: rgba(17, 24, 39, 0.4);
border-color: rgba(55, 65, 81, 0.6);
}
.variable-chip {
cursor: pointer;
}
.chip-text {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
border-radius: 0.25rem;
background: #e2e8f0;
color: #64748b;
transition: all 0.15s;
user-select: none;
}
:global(.dark) .chip-text {
background: rgba(55, 65, 81, 0.6);
color: #9ca3af;
}
.chip-text:hover {
background: #cbd5e1;
color: #475569;
}
:global(.dark) .chip-text:hover {
background: rgba(75, 85, 99, 0.8);
color: #d1d5db;
}
.chip-text.selected {
background: #3b82f6;
color: white;
}
:global(.dark) .chip-text.selected {
background: #2563eb;
color: white;
}
.chip-text.selected:hover {
background: #2563eb;
}
:global(.dark) .chip-text.selected:hover {
background: #1d4ed8;
}
.section-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
background: #e2e8f0;
border-radius: 0.25rem;
color: #475569;
}
:global(.dark) .section-content code {
background: rgba(55, 65, 81, 0.6);
color: #d1d5db;
}
</style>