cleanup from old proxy page test

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-09-30 21:56:44 +02:00
parent aca7aa6a6b
commit f9365ab299
7 changed files with 71 additions and 487 deletions
+6 -9
View File
@@ -13,15 +13,12 @@ const (
// Page is a gorm data model
type Page struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_pages_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_pages_unique_name_and_company_id;"`
Content string `gorm:"not null;"`
Type string `gorm:"not null;default:'regular';"`
TargetURL string
ProxyConfig string
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_pages_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_pages_unique_name_and_company_id;"`
Content string `gorm:"not null;"`
// could has-one
Company *Company
+8 -56
View File
@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
@@ -12,15 +11,12 @@ import (
// Page is a Page
type Page struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
Type nullable.Nullable[vo.String32] `json:"type"` // "regular" or "proxy"
TargetURL nullable.Nullable[vo.OptionalString1024] `json:"targetURL"` // target url for proxy pages
ProxyConfig nullable.Nullable[vo.OptionalString1MB] `json:"proxyConfig"` // yaml configuration for proxy
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
Company *Company `json:"-"`
}
@@ -31,34 +27,8 @@ func (p *Page) Validate() error {
return err
}
// set default type if not specified
if !p.Type.IsSpecified() {
p.Type.Set(*vo.NewString32Must("regular"))
}
pageType, err := p.Type.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("type is required"), "type")
}
// validate type is either "regular" or "proxy"
if pageType.String() != "regular" && pageType.String() != "proxy" {
return validate.WrapErrorWithField(errors.New("type must be 'regular' or 'proxy'"), "type")
}
if pageType.String() == "proxy" {
// proxy pages require targetURL and proxyConfig
if err := validate.NullableFieldRequired("targetURL", p.TargetURL); err != nil {
return err
}
if err := validate.NullableFieldRequired("proxyConfig", p.ProxyConfig); err != nil {
return err
}
} else {
// regular pages require content
if err := validate.NullableFieldRequired("content", p.Content); err != nil {
return err
}
if err := validate.NullableFieldRequired("content", p.Content); err != nil {
return err
}
return nil
@@ -81,24 +51,6 @@ func (p *Page) ToDBMap() map[string]any {
m["content"] = content.String()
}
}
if p.Type.IsSpecified() {
m["type"] = "regular"
if pageType, err := p.Type.Get(); err == nil {
m["type"] = pageType.String()
}
}
if p.TargetURL.IsSpecified() {
m["target_url"] = nil
if targetURL, err := p.TargetURL.Get(); err == nil {
m["target_url"] = targetURL.String()
}
}
if p.ProxyConfig.IsSpecified() {
m["proxy_config"] = nil
if proxyConfig, err := p.ProxyConfig.Get(); err == nil {
m["proxy_config"] = proxyConfig.String()
}
}
if p.CompanyID.IsSpecified() {
if p.CompanyID.IsNull() {
m["company_id"] = nil
+6 -28
View File
@@ -254,34 +254,12 @@ func ToPage(row *database.Page) (*model.Page, error) {
}
content := nullable.NewNullableWithValue(*c)
// Handle proxy fields
typeValue := row.Type
if typeValue == "" {
typeValue = "regular"
}
pageType := nullable.NewNullableWithValue(*vo.NewString32Must(typeValue))
targetURL, err := vo.NewOptionalString1024(row.TargetURL)
if err != nil {
return nil, errs.Wrap(err)
}
targetURLNullable := nullable.NewNullableWithValue(*targetURL)
proxyConfig, err := vo.NewOptionalString1MB(row.ProxyConfig)
if err != nil {
return nil, errs.Wrap(err)
}
proxyConfigNullable := nullable.NewNullableWithValue(*proxyConfig)
return &model.Page{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Content: content,
Type: pageType,
TargetURL: targetURLNullable,
ProxyConfig: proxyConfigNullable,
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Content: content,
}, nil
}
+10 -190
View File
@@ -2,12 +2,8 @@ package service
import (
"context"
"net/url"
"regexp"
"strings"
"github.com/go-errors/errors"
"gopkg.in/yaml.v3"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
@@ -15,7 +11,6 @@ import (
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
@@ -29,39 +24,6 @@ type Page struct {
DomainRepository *repository.Domain
}
// ProxyConfig represents the YAML configuration for proxy pages
type ProxyConfig struct {
Default map[string]interface{} `yaml:"default,omitempty"`
Hosts map[string]ProxyHostConfig `yaml:",inline"`
}
// ProxyHostConfig represents configuration for a specific host
type ProxyHostConfig struct {
Proxy string `yaml:"proxy,omitempty"`
Domain string `yaml:"domain,omitempty"`
Capture []ProxyCaptureRule `yaml:"capture,omitempty"`
Replace []ProxyReplaceRule `yaml:"replace,omitempty"`
}
// ProxyCaptureRule represents a capture rule
type ProxyCaptureRule struct {
Name string `yaml:"name"`
Method string `yaml:"method,omitempty"`
Path string `yaml:"path,omitempty"`
Pattern string `yaml:"pattern,omitempty"`
Find string `yaml:"find"`
From string `yaml:"from,omitempty"`
Required *bool `yaml:"required,omitempty"`
}
// ProxyReplaceRule represents a replace rule
type ProxyReplaceRule struct {
Name string `yaml:"name"`
Find string `yaml:"find"`
Replace string `yaml:"replace"`
From string `yaml:"from,omitempty"`
}
// Create creates a new page
func (p *Page) Create(
ctx context.Context,
@@ -89,20 +51,11 @@ func (p *Page) Create(
p.Logger.Errorw("failed to validate page", "error", err)
return nil, errs.Wrap(err)
}
// validate based on page type
pageType, _ := page.Type.Get()
if pageType.String() == "proxy" {
// validate proxy configuration
if err := p.validateProxyPage(ctx, page); err != nil {
return nil, err
}
} else {
// validate template content for regular pages
if content, err := page.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
// validate template content
if content, err := page.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
}
// check uniqueness
@@ -308,33 +261,15 @@ func (p *Page) UpdateByID(
}
current.Name.Set(v)
}
if v, err := page.Type.Get(); err == nil {
current.Type.Set(v)
}
if v, err := page.TargetURL.Get(); err == nil {
current.TargetURL.Set(v)
}
if v, err := page.ProxyConfig.Get(); err == nil {
current.ProxyConfig.Set(v)
}
if v, err := page.Content.Get(); err == nil {
current.Content.Set(v)
}
// validate based on updated page type
updatedPageType, _ := current.Type.Get()
if updatedPageType.String() == "proxy" {
// validate proxy configuration
if err := p.validateProxyPage(ctx, current); err != nil {
return err
}
} else {
// validate template content for regular pages
if content, err := current.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
// validate template content
if content, err := current.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
}
// update page
@@ -353,121 +288,6 @@ func (p *Page) UpdateByID(
return nil
}
// validateProxyPage validates proxy page configuration
func (p *Page) validateProxyPage(ctx context.Context, page *model.Page) error {
// validate target URL format
targetURL, err := page.TargetURL.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("target URL is required for proxy pages"), "targetURL")
}
parsedURL, err := url.Parse(targetURL.String())
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return validate.WrapErrorWithField(errors.New("invalid target URL format - must be a valid HTTP or HTTPS URL"), "targetURL")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return validate.WrapErrorWithField(errors.New("target URL must use HTTP or HTTPS protocol"), "targetURL")
}
// validate proxy configuration YAML
proxyConfig, err := page.ProxyConfig.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("proxy configuration is required for proxy pages"), "proxyConfig")
}
var config ProxyConfig
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return validate.WrapErrorWithField(errors.New("invalid YAML format: "+err.Error()), "proxyConfig")
}
// validate that all referenced domains in the config support proxy
for hostname, hostConfig := range config.Hosts {
if hostConfig.Domain != "" {
domainName, err := vo.NewString255(hostConfig.Domain)
if err != nil {
return validate.WrapErrorWithField(
errors.New("invalid domain name format"),
"proxyConfig",
)
}
_, err = p.DomainRepository.GetByName(ctx, domainName, &repository.DomainOption{})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return validate.WrapErrorWithField(
errors.New("referenced domain '"+hostConfig.Domain+"' not found"),
"proxyConfig",
)
}
return err
}
}
// validate capture rules
for _, capture := range hostConfig.Capture {
if capture.Name == "" {
return validate.WrapErrorWithField(errors.New("capture rule name is required"), "proxyConfig")
}
if capture.Pattern == "" && capture.Path == "" {
return validate.WrapErrorWithField(
errors.New("capture rule must have either pattern or path"),
"proxyConfig",
)
}
if capture.Pattern != "" {
if _, err := regexp.Compile(capture.Pattern); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in capture rule: "+err.Error()),
"proxyConfig",
)
}
}
if capture.Path != "" {
if _, err := regexp.Compile(capture.Path); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern for path in capture rule: "+err.Error()),
"proxyConfig",
)
}
}
if capture.From != "" {
validFromValues := []string{"request_body", "request_header", "response_body", "response_header", "any"}
valid := false
for _, validFrom := range validFromValues {
if capture.From == validFrom {
valid = true
break
}
}
if !valid {
return validate.WrapErrorWithField(
errors.New("invalid 'from' value in capture rule, must be one of: "+strings.Join(validFromValues, ", ")),
"proxyConfig",
)
}
}
}
// validate replace rules
for _, replace := range hostConfig.Replace {
if replace.Find == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'find' is required"), "proxyConfig")
}
if _, err := regexp.Compile(replace.Find); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in replace rule 'find': "+err.Error()),
"proxyConfig",
)
}
}
p.Logger.Debugw("validated proxy host config", "hostname", hostname)
}
return nil
}
// DeleteByID deletes a page by ID
func (p *Page) DeleteByID(
ctx context.Context,
+2 -4
View File
@@ -1314,15 +1314,13 @@ export class API {
* @param {string} name
* @param {string} content
* @param {string} companyID
* @param {object} additionalFields - Optional additional fields for Proxy pages
* @returns {Promise<ApiResponse>}
*/
create: async (name, content, companyID, additionalFields = {}) => {
create: async (name, content, companyID) => {
const payload = {
name: name,
content: content,
companyID: companyID,
...additionalFields
companyID: companyID
};
return await postJSON(this.getPath('/page'), payload);
},
@@ -388,11 +388,19 @@
isDetailsVisible = true;
}}
type="button"
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md w-36 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 mb-2 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 transition-colors duration-200"
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 mb-2 transition-colors duration-200"
class:font-bold={isDetailsVisible}
class:bg-cta-blue={isDetailsVisible}
class:dark:bg-indigo-600={isDetailsVisible}
class:bg-blue-600={isDetailsVisible}
class:dark:bg-blue-500={isDetailsVisible}
class:text-white={isDetailsVisible}
class:border-blue-600={isDetailsVisible}
class:dark:border-blue-500={isDetailsVisible}
class:text-gray-700={!isDetailsVisible}
class:dark:text-gray-200={!isDetailsVisible}
class:bg-white={!isDetailsVisible}
class:dark:bg-gray-700={!isDetailsVisible}
class:border-gray-300={!isDetailsVisible}
class:dark:border-gray-600={!isDetailsVisible}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -414,11 +422,19 @@
isDetailsVisible = false;
}}
type="button"
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md w-36 text-center cursor-pointer hover:opacity-80 ml-1 flex items-center justify-center gap-2 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 transition-colors duration-200"
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 ml-1 flex items-center justify-center gap-2 transition-colors duration-200"
class:font-bold={!isDetailsVisible}
class:bg-cta-blue={!isDetailsVisible}
class:dark:bg-indigo-600={!isDetailsVisible}
class:bg-blue-600={!isDetailsVisible}
class:dark:bg-blue-500={!isDetailsVisible}
class:text-white={!isDetailsVisible}
class:border-blue-600={!isDetailsVisible}
class:dark:border-blue-500={!isDetailsVisible}
class:text-gray-700={isDetailsVisible}
class:dark:text-gray-200={isDetailsVisible}
class:bg-white={isDetailsVisible}
class:dark:bg-gray-700={isDetailsVisible}
class:border-gray-300={isDetailsVisible}
class:dark:border-gray-600={isDetailsVisible}
>
<svg
xmlns="http://www.w3.org/2000/svg"
+17 -194
View File
@@ -18,7 +18,7 @@
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
@@ -34,7 +34,6 @@
import { fetchAllRows } from '$lib/utils/api-utils';
import { BiMap } from '$lib/utils/maps';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
// services
const appStateService = AppStateService.instance;
@@ -44,10 +43,7 @@
let formValues = {
id: null,
name: null,
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
content: null
};
let isSubmitting = false;
@@ -68,54 +64,6 @@
name: null
};
// proxy example configuration - simplified to only capture and replacement rules
const proxyExample = `capture:
- name: 'login credentials'
method: 'POST' # optional, default GET
path: '/login' # regex path pattern - matches /login exactly
find: 'username=([^&]+)&password=([^&]+)' # REQUIRED - regex pattern to capture data
from: 'request_body' # where to capture from: request_body, request_header, response_body, response_header, any
# required: true # default - all captures are required unless explicitly set to false
- name: 'has completed login'
method: 'GET'
path: '/secure' # navigation tracking - just checks if user visited this path
# no find pattern needed for path-based navigation tracking
# required: true # default - user must visit /secure before campaign progresses
- name: 'form submission'
method: 'POST'
path: '/submit-data' # tracks POST requests to this endpoint
# no find pattern needed - just tracking that the form was submitted
- name: 'profile update'
method: 'PUT'
path: '/api/profile' # tracks PUT requests for profile updates
# navigation tracking works with any HTTP method
- name: 'api tokens'
path: '/api/v\\d+/auth.*' # regex - matches /api/v1/auth, /api/v2/auth/token, etc.
find: 'token=([a-zA-Z0-9]+)' # REQUIRED - all captures must have a find pattern
from: 'response_body'
- name: 'optional tracking data'
path: '^/dashboard' # regex - matches paths starting with /dashboard
find: 'session_id=([a-f0-9]+)' # REQUIRED - find pattern is mandatory
from: 'response_header'
required: false # explicitly mark as optional - campaign will progress without this
replace:
- name: 'replace logo'
find: 'https://target\\.example\\.com/logo\\.png'
replace: 'https://evil.domain.com/assets/logo.png'
- name: 'replace links'
find: 'href="([^"]*target\\.example\\.com[^"]*)"'
replace: 'href="https://evil.domain.com$1"'`;
$: isRegularPage = formValues.type === 'regular';
$: isProxyPage = formValues.type === 'proxy';
$: {
modalText = getModalText('page', modalMode);
}
@@ -209,20 +157,7 @@ replace:
const create = async () => {
try {
const pageData = {
name: formValues.name,
type: formValues.type,
content: isRegularPage ? formValues.content : null,
targetURL: isProxyPage ? formValues.targetURL : null,
proxyConfig: isProxyPage ? formValues.proxyConfig : null
};
const res = await api.page.create(
pageData.name,
pageData.content,
contextCompanyID,
pageData
);
const res = await api.page.create(formValues.name, formValues.content, contextCompanyID);
if (!res.success) {
formError = res.error;
return;
@@ -240,10 +175,7 @@ replace:
try {
const updateData = {
name: formValues.name,
type: formValues.type,
content: isRegularPage ? formValues.content : null,
targetURL: isProxyPage ? formValues.targetURL : null,
proxyConfig: isProxyPage ? formValues.proxyConfig : null
content: formValues.content
};
const res = await api.page.update(formValues.id, updateData);
@@ -288,9 +220,6 @@ replace:
formValues.content = '';
formValues.name = '';
formValues.id = '';
formValues.type = 'regular';
formValues.targetURL = '';
formValues.proxyConfig = '';
form.reset();
formError = '';
};
@@ -305,10 +234,7 @@ replace:
formValues = {
id: null,
name: null,
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
content: null
};
try {
@@ -337,10 +263,7 @@ replace:
formValues = {
id: null,
name: null,
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
content: null
};
try {
@@ -366,9 +289,6 @@ replace:
formValues.id = page.id;
formValues.name = page.name;
formValues.content = page.content || '';
formValues.type = page.type && page.type.trim() !== '' ? page.type : 'regular';
formValues.targetURL = page.targetURL || '';
formValues.proxyConfig = page.proxyConfig || '';
};
/** @param {*} event */
@@ -440,115 +360,18 @@ replace:
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
<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-2 gap-6">
<div>
<TextField
required
minLength={1}
maxLength={64}
bind:value={formValues.name}
placeholder="Intranet login">Name</TextField
>
</div>
<div>
<div class="w-full">
<div class="flex flex-col py-2">
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
>
Type
</p>
</div>
<div class="flex space-x-4">
<label
class="flex items-center space-x-2 px-3 py-2 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200"
class:bg-blue-50={formValues.type === 'regular'}
class:border-blue-300={formValues.type === 'regular'}
class:dark:bg-blue-900={formValues.type === 'regular'}
>
<input
type="radio"
bind:group={formValues.type}
value="regular"
class="text-blue-600"
/>
<span class="text-sm text-slate-600 dark:text-gray-300">📄 Regular</span>
</label>
<label
class="flex items-center space-x-2 px-3 py-2 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200"
class:bg-blue-50={formValues.type === 'proxy'}
class:border-blue-300={formValues.type === 'proxy'}
class:dark:bg-blue-900={formValues.type === 'proxy'}
>
<input
type="radio"
bind:group={formValues.type}
value="proxy"
class="text-blue-600"
/>
<span
class="text-sm text-slate-600 dark:text-gray-300 flex items-center gap-1"
>
<ProxySvgIcon size="w-4 h-4" />
Proxy
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<Editor contentType="page" {domainMap} bind:value={formValues.content}>
<div class="pl-4">
<TextField
minLength={1}
maxLength={64}
required
bind:value={formValues.name}
placeholder="Intranet login">Name</TextField
>
</div>
<!-- Content Configuration Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
{#if isProxyPage}
Proxy Configuration
{:else}
Page Content
{/if}
</h3>
{#if isRegularPage}
<Editor contentType="page" {domainMap} bind:value={formValues.content} />
{/if}
{#if isProxyPage}
<div class="space-y-6">
<div class="flex flex-col py-2 w-full">
<div class="flex items-center">
<p class="font-bold text-slate-600 dark:text-gray-300 py-2">
Proxy Capture & Replacement Rules (YAML)
</p>
<div class="ml-2 text-xs text-gray-500">
Data captures require a 'find' pattern. Path-based navigation tracking (any
method) doesn't need 'find'. All captures are required by default.
</div>
</div>
<div class="w-80vw">
<SimpleCodeEditor
bind:value={formValues.proxyConfig}
height="medium"
language="yaml"
placeholder={proxyExample}
/>
</div>
</div>
</div>
{/if}
</div>
<FormError message={formError} />
</div>
</Editor>
<FormError message={formError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>