Proxy visual mode

Proxy Import / Export
This commit is contained in:
Ronni Skansing
2026-01-29 12:41:15 +01:00
parent e508190f35
commit 6b8f05cff1
8 changed files with 6251 additions and 86 deletions
+2
View File
@@ -57,6 +57,8 @@ See [production docker compose example](https://github.com/phishingclub/phishing
- **Browser impersonation** - Impersonate JA4 fingerprints in proxied requests
- **Response overwriting** - Shortcut proxying with custom responses
- **Forward proxying** - Use HTTP and SOCKS5 proxies to ensure requests originate from the right location
- **Visual Editor** - Use the visual editor to easily setup a proxy
- **Import compromised oauth token** - Use compromised tokens to send more phishing via. oauth enabled endpoints
### Blogs & Resources
- [Covert red team phishing with Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/)
+51 -42
View File
@@ -11,6 +11,7 @@
export let bindTo = null;
export let resetTabFocus = () => {};
export let noAutoFocus = false;
export let fullscreen = false;
let modalElement;
let previousActiveElement;
@@ -25,7 +26,7 @@
// only handle focus when modal visibility actually changes and not during submission
if (visible && !modalInitialized) {
window.addEventListener('keydown', keyHandler);
// Prevent body scrolling when modal is open
// prevent body scrolling when modal is open
document.body.style.overflow = 'hidden';
if (!isSubmitting) {
handleModalOpen();
@@ -34,7 +35,7 @@
wasVisible = true;
} else if (!visible && modalInitialized) {
window.removeEventListener('keydown', keyHandler);
// Restore body scrolling when modal is closed
// restore body scrolling when modal is closed
document.body.style.overflow = 'auto';
handleModalClose();
modalInitialized = false;
@@ -49,13 +50,13 @@
close();
}
} else if (e.key === 'Tab') {
// Check if the focused element is a dropdown option button
// check if the focused element is a dropdown option button
const focusedElement = document.activeElement;
const isDropdownOption =
focusedElement?.closest('[role="listbox"]') && focusedElement?.role === 'option';
if (isDropdownOption) {
// Don't intercept tab from dropdown options - let them handle it first
// don't intercept tab from dropdown options - let them handle it first
return;
}
@@ -64,62 +65,62 @@
};
const handleTabKey = (e) => {
// Store the current focused element before updating the list
// store the current focused element before updating the list
const currentlyFocused = document.activeElement;
// Update focusable elements before handling tab to account for dynamic changes
// update focusable elements before handling tab to account for dynamic changes
updateFocusableElements();
if (focusableElements.length === 0) return;
// Always prevent default tab behavior to keep focus within modal
// always prevent default tab behavior to keep focus within modal
e.preventDefault();
let currentIndex = focusableElements.indexOf(currentlyFocused);
// If current element is not found (-1), try to find a related element
// if current element is not found (-1), try to find a related element
if (currentIndex === -1) {
// Check if the current element is inside a TextFieldSelect or similar component
// check if the current element is inside a TextFieldSelect or similar component
const parentComponent = currentlyFocused?.closest('.textfield-select-container');
if (parentComponent) {
// Look for the input element within the same component
// look for the input element within the same component
const inputInComponent = parentComponent.querySelector('input');
if (inputInComponent) {
const inputIndex = focusableElements.indexOf(inputInComponent);
if (inputIndex !== -1) {
// Use the input's position for navigation and continue tab flow
// use the input's position for navigation and continue tab flow
currentIndex = inputIndex;
}
}
}
}
// If we still can't find the element, handle it gracefully
// if we still can't find the element, handle it gracefully
if (currentIndex === -1) {
// Check if the current element is inside a TextFieldSelect or similar component
// check if the current element is inside a TextFieldSelect or similar component
const parentComponent = currentlyFocused?.closest('.textfield-select-container');
if (parentComponent) {
// Look for the input element within the same component
// look for the input element within the same component
const inputInComponent = parentComponent.querySelector('input');
if (inputInComponent) {
const inputIndex = focusableElements.indexOf(inputInComponent);
if (inputIndex !== -1) {
// Use the input's position for navigation
// use the input's position for navigation
currentIndex = inputIndex;
}
}
}
}
// If we still can't find the element, handle it gracefully
// if we still can't find the element, handle it gracefully
if (currentIndex === -1) {
// If we can't find the element or related element, try to be smarter
// Check if we should go forward or backward based on the shift key
// if we can't find the element or related element, try to be smarter
// check if we should go forward or backward based on the shift key
if (e.shiftKey) {
// Shift+Tab: go to last element
// shift+Tab: go to last element
lastFocusableElement?.focus();
} else {
// Tab: try to find the first input in the form, or fallback to first element
// tab: try to find the first input in the form, or fallback to first element
const firstInput = focusableElements.find((el) => el.tagName === 'INPUT');
if (firstInput) {
firstInput.focus();
@@ -130,23 +131,23 @@
return;
}
// Now handle normal tab navigation
// now handle normal tab navigation
if (e.shiftKey) {
// Shift + Tab - go to previous element
// shift + Tab - go to previous element
if (currentIndex <= 0) {
// If at first element, go to last
// if at first element, go to last
lastFocusableElement?.focus();
} else {
// Go to previous element
// go to previous element
focusableElements[currentIndex - 1]?.focus();
}
} else {
// Tab - go to next element
// tab - go to next element
if (currentIndex >= focusableElements.length - 1) {
// If at last element, go to first
// if at last element, go to first
firstFocusableElement?.focus();
} else {
// Go to next element
// go to next element
focusableElements[currentIndex + 1]?.focus();
}
}
@@ -206,7 +207,7 @@
const updateFocusableElements = () => {
focusableElements = getFocusableElements();
// Reorder elements: form controls first, then navigation buttons, then close button
// reorder elements: form controls first, then navigation buttons, then close button
const formElements = [];
const navigationButtons = [];
const closeButton = modalElement?.querySelector('[data-close-button]');
@@ -226,7 +227,7 @@
}
});
// Rebuild focusable elements in desired order: form controls, then navigation, then close
// rebuild focusable elements in desired order: form controls, then navigation, then close
focusableElements = [...formElements, ...navigationButtons];
if (closeButton) {
focusableElements.push(closeButton);
@@ -242,37 +243,37 @@
return;
}
// Store the currently focused element
// store the currently focused element
previousActiveElement = document.activeElement;
// Wait for the DOM to update
// wait for the DOM to update
await tick();
updateFocusableElements();
// Focus the first focusable element (excluding close button)
// focus the first focusable element (excluding close button)
if (!noAutoFocus && firstFocusableElement) {
firstFocusableElement.focus();
}
};
const handleModalClose = () => {
// Restore focus to the previously focused element
// restore focus to the previously focused element
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus();
}
};
// Exposed function to reset tab focus when modal content changes
// exposed function to reset tab focus when modal content changes
const handleResetTabFocus = async () => {
// Check if a TextFieldSelect is currently selecting an option
// check if a TextFieldSelect is currently selecting an option
const isTextFieldSelectActive = modalElement?.querySelector('[data-selecting="true"]');
await tick();
updateFocusableElements();
};
// Call resetTabFocus when it changes
// call resetTabFocus when it changes
$: if (resetTabFocus) {
resetTabFocus = handleResetTabFocus;
}
@@ -280,9 +281,9 @@
onMount(() => {
return () => {
window.removeEventListener('keydown', keyHandler);
// Ensure body scrolling is restored when component is destroyed
// ensure body scrolling is restored when component is destroyed
document.body.style.overflow = 'auto';
// Restore focus if modal was open when component was destroyed
// restore focus if modal was open when component was destroyed
if (visible && previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus();
}
@@ -306,7 +307,7 @@
}
visible = false;
window.removeEventListener('keydown', keyHandler);
// Restore body scrolling when modal is closed
// restore body scrolling when modal is closed
document.body.style.overflow = 'auto';
onClose();
};
@@ -324,11 +325,15 @@
>
<section
bind:this={modalElement}
class="shadow-xl dark:shadow-gray-900/70 w-auto ml-20 mr-8 max-h-[90vh] bg-white dark:bg-gray-800 opacity-100 rounded-md flex flex-col transition-colors duration-200"
class="shadow-xl dark:shadow-gray-900/70 bg-white dark:bg-gray-800 opacity-100 rounded-md flex flex-col transition-all duration-200
{fullscreen
? 'fixed inset-0 w-full h-full max-w-none max-h-none rounded-none'
: 'w-auto ml-20 mr-8 max-h-[90vh]'}"
>
<div
class:opacity-20={isSubmitting}
class="bg-cta-blue dark:bg-blue-800 text-white rounded-t-md py-4 px-8 flex justify-between flex-shrink-0 transition-colors duration-200"
class="bg-cta-blue dark:bg-blue-800 text-white py-4 px-8 flex justify-between flex-shrink-0 transition-colors duration-200
{fullscreen ? '' : 'rounded-t-md'}"
>
<div class="flex-1">
<h1 id="modal-title" class="uppercase mr-8 font-semibold text-2xl">{headerText}</h1>
@@ -346,7 +351,11 @@
<img class="w-full" src="/close-white.svg" alt="" />
</button>
</div>
<div class="px-8 overflow-y-auto overflow-x-visible {scrollBarClassesVertical}">
<div
class="px-8 overflow-y-auto overflow-x-visible {scrollBarClassesVertical} {fullscreen
? 'flex-1'
: ''}"
>
<slot />
</div>
</section>
+4 -1
View File
@@ -23,6 +23,7 @@
export let pattern = null;
export let id = null;
export let onBlur = () => {};
export let error = false;
// type can only be set initially
export let type = 'text';
let inputType = 'text';
@@ -97,7 +98,9 @@
{required}
{placeholder}
{pattern}
class="text-ellipsis row-start-1 row-span-3 justify-self-center 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"
class="text-ellipsis row-start-1 row-span-3 justify-self-center rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border focus:outline-none focus:border-solid focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal transition-colors duration-200 {error
? 'border-red-500 dark:border-red-400'
: 'border-transparent dark:border-gray-700/60 focus:border-slate-400 dark:focus:border-highlight-blue/80'}"
class:w-24={width === 'small'}
class:w-60={width === 'medium'}
class:w-95={width === 'large'}
@@ -21,6 +21,31 @@
// Ensure options is always an array
$: optionsArray = Array.isArray(options) ? options : Array.from(options);
// helper to get display label for an option (supports both string and {value, label} objects)
const getOptionLabel = (opt) => {
if (opt && typeof opt === 'object' && 'label' in opt) {
return opt.label;
}
return opt;
};
// helper to get value for an option (supports both string and {value, label} objects)
const getOptionValue = (opt) => {
if (opt && typeof opt === 'object' && 'value' in opt) {
return opt.value;
}
return opt;
};
// get display label for current value
const getDisplayLabel = (val) => {
const found = optionsArray.find((opt) => getOptionValue(opt) === val);
if (found) {
return getOptionLabel(found);
}
return val;
};
let allOptions = [];
let showDropdown = false;
let inputElement;
@@ -33,9 +58,10 @@
if (!searchValue) {
return [...optionsArray];
}
return optionsArray.filter(
(opt) => opt && opt.toLowerCase && opt.toLowerCase().includes(searchValue.toLowerCase())
);
return optionsArray.filter((opt) => {
const label = getOptionLabel(opt);
return label && label.toLowerCase && label.toLowerCase().includes(searchValue.toLowerCase());
});
};
// Track if user has typed (for filtering) vs just focused (show all)
@@ -68,11 +94,11 @@
// Select an option
const selectOption = (option) => {
value = option;
value = getOptionValue(option);
showDropdown = false;
hasTyped = false; // Reset typing flag after selection
justSelected = true; // Set flag to prevent dropdown reopening
onSelect(option);
onSelect(value);
// Focus the input field after selection without reopening dropdown
setTimeout(() => {
if (inputElement) {
@@ -244,6 +270,7 @@
const labelId = `${comboboxId}-label`;
// Reactive statements for accessibility
$: displayValue = getDisplayLabel(value);
$: hasValue = value && value !== '';
$: ariaExpanded = showDropdown;
</script>
@@ -292,7 +319,7 @@
aria-controls={listboxId}
aria-autocomplete="list"
aria-haspopup="listbox"
bind:value
value={showDropdown ? value : displayValue}
on:focus={handleFocus}
on:blur={handleBlur}
on:input={handleInput}
@@ -358,7 +385,7 @@
<button
id="{listboxId}-option-{index}"
role="option"
aria-selected={value === option}
aria-selected={value === getOptionValue(option)}
class="w-full text-left bg-slate-100 dark:bg-gray-900 rounded-md text-gray-600 dark:text-gray-300 hover:bg-grayblue-dark dark:hover:bg-highlight-blue/40 hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark dark:focus:bg-highlight-blue/40 focus:text-white focus:outline-none transition-colors duration-200"
on:click={(e) => {
e.preventDefault();
@@ -368,7 +395,7 @@
on:keydown={(e) => handleOptionKeyDown(e, option)}
on:blur={handleBlur}
>
{option}
{getOptionLabel(option)}
</button>
</li>
{/each}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
// ES module wrapper for js-yaml UMD bundle
import './js-yaml.js';
// the UMD bundle assigns to globalThis.jsyaml
const jsyaml = globalThis.jsyaml;
export const load = jsyaml.load;
export const dump = jsyaml.dump;
export const loadAll = jsyaml.loadAll;
export const Schema = jsyaml.Schema;
export const Type = jsyaml.Type;
export const YAMLException = jsyaml.YAMLException;
export const CORE_SCHEMA = jsyaml.CORE_SCHEMA;
export const DEFAULT_SCHEMA = jsyaml.DEFAULT_SCHEMA;
export const FAILSAFE_SCHEMA = jsyaml.FAILSAFE_SCHEMA;
export const JSON_SCHEMA = jsyaml.JSON_SCHEMA;
export default jsyaml;
File diff suppressed because it is too large Load Diff
+299 -35
View File
@@ -29,12 +29,14 @@
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import ProxyConfigBuilder from '$lib/components/proxy/ProxyConfigBuilder.svelte';
// services
const appStateService = AppStateService.instance;
// bindings
let form = null;
let proxyConfigBuilder = null;
let formValues = {
id: null,
name: null,
@@ -66,6 +68,147 @@
let selectedProxyForIPList = null;
let isLoadingIPAllowList = false;
// counter to force ProxyConfigBuilder recreation when modal opens
let modalOpenCounter = 0;
// file input reference for YAML mode import
let yamlFileInput = null;
// import configuration from YAML string with metadata (for YAML mode)
function importYamlConfig(yamlStr) {
if (!yamlStr || yamlStr.trim() === '') {
return;
}
try {
// dynamically import js-yaml
import('$lib/components/yaml/index.js').then((jsyaml) => {
const parsed = jsyaml.default.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) {
formValues.name = parsed._general.name;
}
if (parsed._general.description) {
formValues.description = parsed._general.description;
}
if (parsed._general.start_url) {
formValues.startURL = parsed._general.start_url;
}
// remove _general from parsed object before serializing back
delete parsed._general;
}
// serialize back to YAML without _meta for the config
const cleanYaml = jsyaml.default.dump(parsed, {
indent: 2,
lineWidth: -1,
quotingType: "'",
forceQuotes: false,
noRefs: true
});
formValues.proxyConfig = cleanYaml;
});
} catch (e) {
console.warn('Failed to parse imported YAML config:', e);
}
}
// export configuration to YAML file with metadata (for YAML mode)
function exportYamlConfig() {
import('$lib/components/yaml/index.js').then((jsyaml) => {
try {
// parse current config
const parsed = formValues.proxyConfig
? jsyaml.default.load(formValues.proxyConfig) || {}
: {};
// build output with _general first
const output = {};
// add general section with proxy metadata
output._general = {};
if (formValues.name) {
output._general.name = formValues.name;
}
if (formValues.description) {
output._general.description = formValues.description;
}
if (formValues.startURL) {
output._general.start_url = formValues.startURL;
}
// merge rest of config
Object.assign(output, parsed);
// serialize to YAML
const yamlContent = jsyaml.default.dump(output, {
indent: 2,
lineWidth: -1,
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;
const safeName = (formValues.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);
} catch (e) {
console.warn('Failed to export YAML config:', e);
}
});
}
// trigger file input for YAML mode import
function triggerYamlImport() {
yamlFileInput?.click();
}
// handle file selection for YAML mode import
function handleYamlImportFile(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') {
importYamlConfig(content);
}
};
reader.readAsText(file);
// reset file input so same file can be imported again
event.target.value = '';
}
// editor mode: 'yaml' or 'gui' - restore from localStorage, default to yaml for safety
const EDITOR_MODE_STORAGE_KEY = 'proxy-editor-mode';
let editorMode =
(typeof localStorage !== 'undefined' && localStorage.getItem(EDITOR_MODE_STORAGE_KEY)) ||
'yaml';
// save editor mode to localStorage when it changes
$: if (typeof localStorage !== 'undefined' && editorMode) {
localStorage.setItem(EDITOR_MODE_STORAGE_KEY, editorMode);
}
// fullscreen mode for modal - automatically true when in GUI mode
$: isModalFullscreen = editorMode === 'gui';
const currentExample = `version: "0.0"
# optional: forward proxy for outbound requests
@@ -220,6 +363,16 @@ portal.example.com:
try {
isSubmitting = true;
const saveOnly = event?.detail?.saveOnly || false;
// validate config when in GUI mode
if (editorMode === 'gui' && proxyConfigBuilder) {
const validation = proxyConfigBuilder.validate();
if (!validation.valid) {
isSubmitting = false;
return;
}
}
if (modalMode === 'create' || modalMode === 'copy') {
await create();
return;
@@ -305,6 +458,7 @@ portal.example.com:
const openCreateModal = () => {
modalMode = 'create';
modalOpenCounter++;
isModalVisible = true;
};
@@ -322,6 +476,7 @@ portal.example.com:
/** @param {string} id */
const openUpdateModal = async (id) => {
modalMode = 'update';
modalOpenCounter++;
showIsLoading();
// reset form values first
@@ -353,6 +508,7 @@ portal.example.com:
const openCopyModal = async (id) => {
modalMode = 'copy';
modalOpenCounter++;
showIsLoading();
// reset form values first
@@ -511,48 +667,140 @@ portal.example.com:
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<Modal
headerText={modalText}
visible={isModalVisible}
onClose={closeModal}
{isSubmitting}
fullscreen={isModalFullscreen}
>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-8">
<!-- Basic Information Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-[1fr_2fr_2fr] gap-4">
<div>
<TextField
required
minLength={1}
maxLength={64}
bind:value={formValues.name}
placeholder="Company Auth Proxy">Name</TextField
>
<div
class="col-span-3 w-full px-6 py-4 {isModalFullscreen
? 'flex flex-col min-h-0 overflow-hidden'
: 'overflow-y-auto space-y-8'}"
>
{#if editorMode === 'yaml'}
<!-- Basic Information Section - only shown in YAML mode -->
<div class="w-full mb-6 pt-4 pb-2 border-b border-gray-200 dark:border-gray-600">
<div class="flex justify-between items-center mb-3">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white">
Basic Information
</h3>
<div class="flex gap-2">
<input
type="file"
accept=".yaml,.yml"
bind:this={yamlFileInput}
on:change={handleYamlImportFile}
class="hidden"
/>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-gray-400 bg-slate-100 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-md hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors"
on:click={triggerYamlImport}
title="Import configuration from YAML file"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Import
</button>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-gray-400 bg-slate-100 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-md hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors"
on:click={exportYamlConfig}
title="Export configuration to YAML file"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Export
</button>
</div>
</div>
<div>
<TextField optional maxLength={255} bind:value={formValues.description}
>Description</TextField
>
</div>
<div class="flex justify-end">
<TextField
required
minLength={3}
bind:value={formValues.startURL}
placeholder="https://login.example.com/auth"
toolTipText="Domain must be in proxy configuration">Start URL</TextField
>
<div class="grid grid-cols-1 md:grid-cols-[1fr_2fr_2fr] gap-4">
<div>
<TextField
required
minLength={1}
maxLength={64}
bind:value={formValues.name}
placeholder="Company Auth Proxy">Name</TextField
>
</div>
<div>
<TextField optional maxLength={255} bind:value={formValues.description}
>Description</TextField
>
</div>
<div class="flex justify-end">
<TextField
required
minLength={3}
bind:value={formValues.startURL}
placeholder="https://login.example.com/auth"
toolTipText="Domain must match a phishing domain in the hosts configuration"
>Start URL</TextField
>
</div>
</div>
</div>
</div>
{/if}
<!-- Proxy Configuration Section -->
<div class="w-full">
<div class="space-y-6">
<div class="flex flex-col py-2 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
<div
class="w-full {isModalFullscreen ? 'flex-1 flex flex-col min-h-0 overflow-hidden' : ''}"
>
<div class={isModalFullscreen ? 'flex flex-col h-full min-h-0' : 'space-y-4'}>
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white">
Proxy Configuration
</h3>
<!-- Editor Mode Tabs -->
<div
class="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors duration-200 {editorMode ===
'yaml'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => (editorMode = 'yaml')}
>
YAML
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors duration-200 {editorMode ===
'gui'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => (editorMode = 'gui')}
>
Visual
</button>
</div>
</div>
{#if editorMode === 'yaml'}
<div class="w-80vw">
<SimpleCodeEditor
bind:value={formValues.proxyConfig}
@@ -562,7 +810,23 @@ portal.example.com:
enableProxyCompletion={true}
/>
</div>
</div>
{:else}
<div class="flex-1 min-h-0 overflow-hidden">
{#key modalOpenCounter}
<ProxyConfigBuilder
bind:this={proxyConfigBuilder}
config={formValues.proxyConfig}
name={formValues.name}
description={formValues.description}
startURL={formValues.startURL}
on:change={(e) => (formValues.proxyConfig = e.detail)}
on:nameChange={(e) => (formValues.name = e.detail)}
on:descriptionChange={(e) => (formValues.description = e.detail)}
on:startURLChange={(e) => (formValues.startURL = e.detail)}
/>
{/key}
</div>
{/if}
</div>
</div>