change access directive and add management for proxy allow list

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-23 12:55:51 +02:00
parent 847a3552b1
commit d6a717ebfa
16 changed files with 961 additions and 443 deletions
+6
View File
@@ -94,6 +94,9 @@ const (
ROUTE_V1_PROXY = "/api/v1/proxy"
ROUTE_V1_PROXY_OVERVIEW = "/api/v1/proxy/overview"
ROUTE_V1_PROXY_ID = "/api/v1/proxy/:id"
// ip allow list
ROUTE_V1_IP_ALLOW_LIST_PROXY_CONFIG = "/api/v1/ip-allow-list/proxy-config/:id"
ROUTE_V1_IP_ALLOW_LIST_CLEAR_PROXY_CONFIG = "/api/v1/ip-allow-list/clear-proxy-config/:id"
// recipient and groups
ROUTE_V1_RECIPIENT = "/api/v1/recipient"
ROUTE_V1_RECIPIENT_IMPORT = "/api/v1/recipient/import"
@@ -339,6 +342,9 @@ func setupRoutes(
POST(ROUTE_V1_PROXY, middleware.SessionHandler, controllers.Proxy.Create).
PATCH(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.UpdateByID).
DELETE(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.DeleteByID).
// ip allow list
GET(ROUTE_V1_IP_ALLOW_LIST_PROXY_CONFIG, middleware.SessionHandler, controllers.IPAllowList.GetEntriesForProxyConfig).
DELETE(ROUTE_V1_IP_ALLOW_LIST_CLEAR_PROXY_CONFIG, middleware.SessionHandler, controllers.IPAllowList.ClearForProxyConfig).
// smtp configuration
GET(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.GetAll).
GET(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.GetByID).
+6
View File
@@ -36,6 +36,7 @@ type Controllers struct {
Update *controller.Update
Import *controller.Import
Backup *controller.Backup
IPAllowList *controller.IPAllowList
}
// NewControllers creates a collection of controllers
@@ -179,6 +180,10 @@ func NewControllers(
Common: common,
BackupService: services.Backup,
}
ipAllowList := &controller.IPAllowList{
Common: common,
IPAllowListService: services.IPAllowList,
}
return &Controllers{
Asset: asset,
@@ -209,5 +214,6 @@ func NewControllers(
Update: update,
Import: importController,
Backup: backup,
IPAllowList: ipAllowList,
}
}
+1 -7
View File
@@ -67,12 +67,6 @@ func NewServer(
logger *zap.SugaredLogger,
certMagicConfig *certmagic.Config,
) *Server {
// setup proxy cookie tracking
cookieName := ""
if option, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyProxyCookieName); err == nil && option != nil {
cookieName = option.Value.String()
}
// setup goproxy-based proxy server
proxyServer := proxy.NewProxyHandler(
logger,
@@ -85,7 +79,7 @@ func NewServer(
repositories.Identifier,
services.Campaign,
services.Template,
cookieName,
services.IPAllowList,
)
// setup proxy session cleanup routine
+3
View File
@@ -36,6 +36,7 @@ type Services struct {
Update *service.Update
Import *service.Import
Backup *service.Backup
IPAllowList *service.IPAllowListService
}
// NewServices creates a collection of services
@@ -164,6 +165,7 @@ func NewServices(
CampaignTemplateService: campaignTemplate,
DomainService: domain,
}
ipAllowListService := service.NewIPAllowListService(logger, repositories.Proxy)
email := &service.Email{
Common: common,
AttachmentPath: attachmentPath,
@@ -272,5 +274,6 @@ func NewServices(
Update: updateService,
Import: importService,
Backup: backupService,
IPAllowList: ipAllowListService,
}
}
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// IPAllowList is the controller for IP allow list management
type IPAllowList struct {
Common
IPAllowListService *service.IPAllowListService
}
// GetEntriesForProxyConfig returns IP allow list entries for a specific proxy configuration
func (c *IPAllowList) GetEntriesForProxyConfig(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse proxy config ID from URL params
proxyConfigID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get entries for proxy config
entries, err := c.IPAllowListService.GetEntriesForProxyConfig(
g.Request.Context(),
session,
proxyConfigID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, entries)
}
// ClearForProxyConfig removes all entries for a specific proxy configuration
func (c *IPAllowList) ClearForProxyConfig(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse proxy config ID from URL params
proxyConfigID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// clear entries for proxy config
count, err := c.IPAllowListService.ClearForProxyConfig(
g.Request.Context(),
session,
proxyConfigID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, map[string]interface{}{
"message": "Entries cleared for proxy configuration",
"cleared_count": count,
})
}
+4 -4
View File
@@ -31,7 +31,7 @@ type CampaignTemplate struct {
// landing page can also be a proxy
LandingProxyID *uuid.UUID `gorm:"type:uuid;index;"`
LandingProxy *Proxy `gorm:"foreignKey:LandingProxyID;references:ID;"`
LandingProxy *Proxy `gorm:"foreignKey:LandingProxyID;references:ID;"`
DomainID *uuid.UUID `gorm:"type:uuid;index;"`
Domain *Domain `gorm:"foreignKey:DomainID"`
@@ -48,16 +48,16 @@ type CampaignTemplate struct {
// before landing page can also be a proxy
BeforeLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
BeforeLandingProxy *Proxy `gorm:"foreignKey:BeforeLandingProxyID;references:ID"`
BeforeLandingProxy *Proxy `gorm:"foreignKey:BeforeLandingProxyID;references:ID"`
AfterLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingPage *Page `gorm:"foreignKey:AfterLandingPageID;references:ID"`
// after landing page can also be a proxy
AfterLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingProxy *Proxy `gorm:"foreignKey:AfterLandingProxyID;references:ID"`
AfterLandingProxy *Proxy `gorm:"foreignKey:AfterLandingProxyID;references:ID"`
AfterLandingPageRedirectURL string `gorm:"not null;"`
AfterLandingPageRedirectURL string `gorm:"not null;default:'';"`
EmailID *uuid.UUID `gorm:"type:uuid;index;"`
Email *Email `gorm:"foreignKey:EmailID;references:ID;"`
+17 -7
View File
@@ -89,9 +89,7 @@ func (c *CampaignTemplate) Validate() error {
}
}
}
if err := validate.NullableFieldRequired("urlPath", c.URLPath); err != nil {
return err
}
// URLPath is optional, no validation needed
// validate that only one type is set per stage
// before landing page: can have neither (optional), or one type, but not both
@@ -198,9 +196,13 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
if c.AfterLandingPageRedirectURL.IsSpecified() {
if c.AfterLandingPageRedirectURL.IsNull() {
m["after_landing_page_redirect_url"] = nil
m["after_landing_page_redirect_url"] = ""
} else {
m["after_landing_page_redirect_url"] = c.AfterLandingPageRedirectURL.MustGet().String()
if v, err := c.AfterLandingPageRedirectURL.Get(); err == nil {
m["after_landing_page_redirect_url"] = v.String()
} else {
m["after_landing_page_redirect_url"] = ""
}
}
}
@@ -239,8 +241,16 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
if v, err := c.StateIdentifierID.Get(); err == nil {
m["state_identifier_id"] = v
}
if v, err := c.URLPath.Get(); err == nil {
m["url_path"] = v.String()
if c.URLPath.IsSpecified() {
if c.URLPath.IsNull() {
m["url_path"] = ""
} else {
if v, err := c.URLPath.Get(); err == nil {
m["url_path"] = v.String()
} else {
m["url_path"] = ""
}
}
}
_, errDomain := c.DomainID.Get()
+35 -93
View File
@@ -115,36 +115,36 @@ type ProxyHandler struct {
IdentifierRepository *repository.Identifier
CampaignService *service.Campaign
TemplateService *service.Template
IPAllowListService *service.IPAllowListService
cookieName string
ipAllowList sync.Map // map[string]int64 (ip+domain -> expiry timestamp)
}
func NewProxyHandler(
logger *zap.SugaredLogger,
pageRepository *repository.Page,
campaignRecipientRepository *repository.CampaignRecipient,
campaignRepository *repository.Campaign,
campaignTemplateRepository *repository.CampaignTemplate,
domainRepository *repository.Domain,
proxyRepository *repository.Proxy,
identifierRepository *repository.Identifier,
pageRepo *repository.Page,
campaignRecipientRepo *repository.CampaignRecipient,
campaignRepo *repository.Campaign,
campaignTemplateRepo *repository.CampaignTemplate,
domainRepo *repository.Domain,
proxyRepo *repository.Proxy,
identifierRepo *repository.Identifier,
campaignService *service.Campaign,
templateService *service.Template,
cookieName string,
ipAllowListService *service.IPAllowListService,
) *ProxyHandler {
return &ProxyHandler{
logger: logger,
sessions: sync.Map{},
PageRepository: pageRepository,
CampaignRecipientRepository: campaignRecipientRepository,
CampaignRepository: campaignRepository,
CampaignTemplateRepository: campaignTemplateRepository,
DomainRepository: domainRepository,
ProxyRepository: proxyRepository,
IdentifierRepository: identifierRepository,
PageRepository: pageRepo,
CampaignRecipientRepository: campaignRecipientRepo,
CampaignRepository: campaignRepo,
CampaignTemplateRepository: campaignTemplateRepo,
DomainRepository: domainRepo,
ProxyRepository: proxyRepo,
IdentifierRepository: identifierRepo,
CampaignService: campaignService,
TemplateService: templateService,
cookieName: cookieName,
IPAllowListService: ipAllowListService,
cookieName: "ps",
}
}
@@ -364,7 +364,10 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC
m.registerPageVisitEvent(req, newSession)
// allow list IP for tunnel mode access
m.allowListIP(req, reqCtx.Domain.Name)
clientIP := m.getClientIP(req)
if clientIP != "" {
m.IPAllowListService.AddIP(clientIP, reqCtx.Domain.ProxyID.String(), 10*time.Minute)
}
} else {
// load existing session
sessionVal, exists := m.sessions.Load(reqCtx.SessionID)
@@ -2401,24 +2404,7 @@ func (m *ProxyHandler) CleanupExpiredSessions() {
}
// cleanup expired IP allow listed entries
ipCleanedCount := 0
currentTime := now.Unix()
m.ipAllowList.Range(func(key, value interface{}) bool {
expiry, ok := value.(int64)
if !ok {
m.ipAllowList.Delete(key)
ipCleanedCount++
return true
}
if currentTime >= expiry {
m.ipAllowList.Delete(key)
ipCleanedCount++
}
return true
})
ipCleanedCount := m.IPAllowListService.ClearExpired()
if ipCleanedCount > 0 {
m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount)
}
@@ -2561,9 +2547,12 @@ func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.Prox
return true, ""
}
// check if IP is allowlisted for this domain (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) {
return true, ""
// check if IP is allowlisted for this proxy config (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil {
clientIP := m.getClientIP(req)
if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) {
return true, ""
}
}
// no lure request and IP not allow listed - deny access
@@ -2580,65 +2569,18 @@ func (m *ProxyHandler) applyDefaultPrivateMode(reqCtx *RequestContext, req *http
return true, ""
}
// check if IP is allow listed for this domain (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) {
return true, ""
// check if IP is allowlisted for this proxy config (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil {
clientIP := m.getClientIP(req)
if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) {
return true, ""
}
}
// no lure request and IP not allow listed - deny with default action
return false, "404"
}
// allowListIP adds an IP address to the allow list for private mode access
func (m *ProxyHandler) allowListIP(req *http.Request, domain string) {
clientIP := m.getClientIP(req)
if clientIP == "" {
return
}
key := clientIP + "-" + domain
// allowlisted for 10 minutes
expiry := time.Now().Add(10 * time.Minute).Unix()
m.ipAllowList.Store(key, expiry)
m.logger.Debugw("IP allow listed for private mode",
"ip", clientIP,
"domain", domain,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
}
// isIPAllowlistedForRequest checks if an IP is allowlisted for a specific domain
func (m *ProxyHandler) isIPAllowlistedForRequest(req *http.Request, domain string) bool {
clientIP := m.getClientIP(req)
if clientIP == "" {
return false
}
key := clientIP + "-" + domain
if expiryVal, exists := m.ipAllowList.Load(key); exists {
expiry := expiryVal.(int64)
if time.Now().Unix() < expiry {
m.logger.Debugw("IP found in allow list for private mode",
"ip", clientIP,
"domain", domain,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
return true
}
// expired, remove it
m.ipAllowList.Delete(key)
m.logger.Debugw("IP allow listed entry expired and removed",
"ip", clientIP,
"domain", domain,
)
}
return false
}
// getClientIP extracts the real client IP from request headers
func (m *ProxyHandler) getClientIP(req *http.Request) string {
// check common proxy headers first
+13 -3
View File
@@ -85,6 +85,10 @@ func (c *CampaignTemplate) Create(
if !campaignTemplate.URLPath.IsSpecified() || campaignTemplate.URLPath.IsNull() {
campaignTemplate.URLPath = nullable.NewNullableWithValue(*vo.NewURLPathMust(""))
}
// if no afterLandingPageRedirectURL set to ''
if !campaignTemplate.AfterLandingPageRedirectURL.IsSpecified() || campaignTemplate.AfterLandingPageRedirectURL.IsNull() {
campaignTemplate.AfterLandingPageRedirectURL = nullable.NewNullableWithValue(*vo.NewOptionalString255Must(""))
}
// validate
if err := campaignTemplate.Validate(); err != nil {
c.Logger.Errorw("failed to validate campaign template", "error", err)
@@ -636,7 +640,8 @@ func (c *CampaignTemplate) UpdateByID(
if v, err := campaignTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
incoming.AfterLandingPageRedirectURL.Set(v)
} else {
incoming.AfterLandingPageRedirectURL.SetNull()
// if AfterLandingPageRedirectURL is null, set to empty string
incoming.AfterLandingPageRedirectURL.Set(*vo.NewOptionalString255Must(""))
}
}
if v, err := campaignTemplate.URLIdentifierID.Get(); err == nil {
@@ -645,8 +650,13 @@ func (c *CampaignTemplate) UpdateByID(
if v, err := campaignTemplate.StateIdentifierID.Get(); err == nil {
incoming.StateIdentifierID.Set(v)
}
if v, err := campaignTemplate.URLPath.Get(); err == nil {
incoming.URLPath.Set(v)
if campaignTemplate.URLPath.IsSpecified() {
if v, err := campaignTemplate.URLPath.Get(); err == nil {
incoming.URLPath.Set(v)
} else {
// if URLPath is null, set to empty string
incoming.URLPath.Set(*vo.NewURLPathMust(""))
}
}
// validate
if err := incoming.Validate(); err != nil {
+264
View File
@@ -0,0 +1,264 @@
package service
import (
"context"
"sync"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"go.uber.org/zap"
)
// IPAllowListEntry represents a single IP allow list entry
type IPAllowListEntry struct {
IP string `json:"ip"`
ProxyConfigID string `json:"proxyConfigID"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
// IPAllowListService manages IP allow listing for proxy configurations
type IPAllowListService struct {
Common
logger *zap.SugaredLogger
allowList sync.Map // map[string]int64 (ip+proxyConfigID -> expiry timestamp)
mu sync.RWMutex
cleanupDone chan bool
ProxyRepository *repository.Proxy
}
// NewIPAllowListService creates a new IP allow list service
func NewIPAllowListService(logger *zap.SugaredLogger, proxyRepo *repository.Proxy) *IPAllowListService {
common := Common{
Logger: logger,
}
service := &IPAllowListService{
Common: common,
logger: logger,
cleanupDone: make(chan bool),
ProxyRepository: proxyRepo,
}
// Start cleanup goroutine
go service.periodicCleanup()
return service
}
// AddIP adds an IP to the allow list for a specific proxy configuration
// must only be called internally and not exposed via. API
func (s *IPAllowListService) AddIP(ip string, proxyConfigID string, duration time.Duration) {
if ip == "" || proxyConfigID == "" {
return
}
key := ip + "-" + proxyConfigID
expiry := time.Now().Add(duration).Unix()
s.allowList.Store(key, expiry)
s.logger.Debugw("IP allow listed",
"ip", ip,
"proxy_config_id", proxyConfigID,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
}
// IsIPAllowed checks if an IP is allowed for a specific proxy configuration
// must only be called internally and not exposed via. API
func (s *IPAllowListService) IsIPAllowed(ip string, proxyConfigID string) bool {
if ip == "" || proxyConfigID == "" {
return false
}
key := ip + "-" + proxyConfigID
if expiryVal, exists := s.allowList.Load(key); exists {
expiry := expiryVal.(int64)
if time.Now().Unix() < expiry {
s.logger.Debugw("IP found in allow list",
"ip", ip,
"proxy_config_id", proxyConfigID,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
return true
}
// Expired, remove it
s.allowList.Delete(key)
s.logger.Debugw("IP allow list entry expired and removed",
"ip", ip,
"proxy_config_id", proxyConfigID,
)
}
return false
}
// GetEntriesForProxyConfig returns allow list entries for a specific proxy configuration
func (s *IPAllowListService) GetEntriesForProxyConfig(
ctx context.Context,
session *model.Session,
proxyConfigID *uuid.UUID,
) ([]IPAllowListEntry, error) {
ae := NewAuditEvent("IPAllowList.GetEntriesForProxyConfig", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
var entries []IPAllowListEntry
now := time.Now()
proxyConfigIDStr := proxyConfigID.String()
s.allowList.Range(func(key, value interface{}) bool {
keyStr := key.(string)
expiry := value.(int64)
expiryTime := time.Unix(expiry, 0)
// Skip expired entries
if now.Unix() >= expiry {
s.allowList.Delete(key)
return true
}
// Parse key to extract IP and proxy config ID
parts := parseAllowListKey(keyStr)
if len(parts) == 2 && parts[1] == proxyConfigIDStr {
entries = append(entries, IPAllowListEntry{
IP: parts[0],
ProxyConfigID: parts[1],
ExpiresAt: expiryTime,
CreatedAt: expiryTime.Add(-10 * time.Minute), // Assume 10 minute duration
})
}
return true
})
ae.Details["proxy_config_id"] = proxyConfigID.String()
if userId, err := session.User.ID.Get(); err == nil {
ae.Details["user_id"] = userId.String()
}
return entries, nil
}
// ClearExpired removes all expired entries from the allow list
func (s *IPAllowListService) ClearExpired() int {
count := 0
now := time.Now().Unix()
s.allowList.Range(func(key, value interface{}) bool {
expiry := value.(int64)
if now >= expiry {
s.allowList.Delete(key)
count++
}
return true
})
if count > 0 {
s.logger.Debugw("Cleaned up expired IP allow list entries", "count", count)
}
return count
}
// ClearForProxyConfig removes all entries for a specific proxy configuration
func (s *IPAllowListService) ClearForProxyConfig(
ctx context.Context,
session *model.Session,
proxyConfigID *uuid.UUID,
) (int, error) {
ae := NewAuditEvent("IPAllowList.ClearForProxyConfig", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return 0, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return 0, errs.ErrAuthorizationFailed
}
count := 0
proxyConfigIDStr := proxyConfigID.String()
s.allowList.Range(func(key, value interface{}) bool {
keyStr := key.(string)
parts := parseAllowListKey(keyStr)
if len(parts) == 2 && parts[1] == proxyConfigIDStr {
s.allowList.Delete(key)
count++
}
return true
})
ae.Details["proxy_config_id"] = proxyConfigID.String()
if userId, err := session.User.ID.Get(); err == nil {
ae.Details["user_id"] = userId.String()
}
return count, nil
}
// periodicCleanup runs periodic cleanup of expired entries
func (s *IPAllowListService) periodicCleanup() {
ticker := time.NewTicker(5 * time.Minute) // Clean up every 5 minutes
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.ClearExpired()
case <-s.cleanupDone:
return
}
}
}
// Stop stops the background cleanup goroutine
func (s *IPAllowListService) Stop() {
close(s.cleanupDone)
}
// parseAllowListKey parses the allow list key format "ip-proxyConfigID"
func parseAllowListKey(key string) []string {
// Find the last occurrence of "-" to handle IPv6 addresses
lastIndex := -1
for i := len(key) - 1; i >= 0; i-- {
if key[i] == '-' {
// Check if this looks like a UUID separator (36 chars after this point)
remaining := key[i+1:]
if len(remaining) == 36 {
// Validate it looks like a UUID
if _, err := uuid.Parse(remaining); err == nil {
lastIndex = i
break
}
}
}
}
if lastIndex == -1 {
return []string{key} // Fallback if parsing fails
}
ip := key[:lastIndex]
proxyConfigID := key[lastIndex+1:]
return []string{ip, proxyConfigID}
}
+25
View File
@@ -2897,6 +2897,31 @@ export class API {
}
};
/**
* ipAllowList is the API for IP Allow List related operations.
*/
ipAllowList = {
/**
* Get IP allow list entries for a specific proxy configuration.
*
* @param {string} proxyConfigID
* @returns {Promise<ApiResponse>}
*/
getForProxyConfig: async (proxyConfigID) => {
return await getJSON(this.getPath(`/ip-allow-list/proxy-config/${proxyConfigID}`));
},
/**
* Clear all entries for a specific proxy configuration.
*
* @param {string} proxyConfigID
* @returns {Promise<ApiResponse>}
*/
clearForProxyConfig: async (proxyConfigID) => {
return await deleteJSON(this.getPath(`/ip-allow-list/clear-proxy-config/${proxyConfigID}`));
}
};
/**
* import is for importing assets, landing pages and etc
*/
+1 -1
View File
@@ -327,7 +327,7 @@
<img class="w-full" src="/close-white.svg" alt="" />
</button>
</div>
<div class="px-8 overflow-y-auto {scrollBarClassesVertical}">
<div class="px-8 overflow-y-auto overflow-x-visible {scrollBarClassesVertical}">
<slot />
</div>
</section>
@@ -26,6 +26,7 @@
let inputElement;
let dropdownElement;
let justSelected = false;
let dropdownPosition = { top: 0, left: 0, width: 0 };
// Simple function to filter options based on input
const filterOptions = (searchValue) => {
@@ -51,6 +52,7 @@
showDropdown = true;
hasTyped = false; // Reset typing flag to show all options
allOptions = [...optionsArray]; // Show all options on focus
updateDropdownPosition();
}
justSelected = false; // Reset the flag
};
@@ -61,6 +63,7 @@
showDropdown = true;
hasTyped = true; // User has typed, enable filtering
allOptions = filterOptions(value);
updateDropdownPosition();
};
// Select an option
@@ -84,6 +87,18 @@
hasTyped = false; // Reset typing flag when closing
};
// Update dropdown position for fixed positioning
const updateDropdownPosition = () => {
if (inputElement) {
const rect = inputElement.getBoundingClientRect();
dropdownPosition = {
top: rect.bottom + 4,
left: rect.left,
width: rect.width
};
}
};
// Handle blur to close dropdown when tabbing out
const handleBlur = (e) => {
// Use setTimeout to allow click events on options to complete first
@@ -187,10 +202,14 @@
value = value || defaultValue;
const unsubscribe = activeFormElementSubscribe(_id, closeDropdown);
document.addEventListener('click', handleOutsideClick);
window.addEventListener('scroll', updateDropdownPosition, true);
window.addEventListener('resize', updateDropdownPosition);
return () => {
unsubscribe();
document.removeEventListener('click', handleOutsideClick);
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
};
});
@@ -215,6 +234,8 @@
parentForm.removeEventListener('reset', parentFormResetListener);
}
document.removeEventListener('click', handleOutsideClick);
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
});
// Generate unique IDs for accessibility
@@ -323,15 +344,14 @@
{#if showDropdown}
<div
bind:this={dropdownElement}
class="absolute top-10 z-50"
class:w-28={size == 'small'}
class:w-60={size == 'normal'}
class="fixed z-[9999]"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; width: {dropdownPosition.width}px;"
>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
class="bg-gray-100 dark:bg-gray-900 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-700/60 max-h-40 overflow-y-scroll transition-colors duration-200"
class="bg-gray-100 dark:bg-gray-900 list-none mt-4 z-[9999] rounded-md min-w-fit shadow-lg border border-gray-200 dark:border-gray-700/60 max-h-40 overflow-y-scroll transition-colors duration-200"
>
{#if allOptions.length}
{#each allOptions as option, index}
+183 -198
View File
@@ -45,22 +45,20 @@
let form = null;
let formValues = {
id: null,
templateType: null,
templateType: 'Email',
name: null,
domain: null,
landingPage: null,
landingPageType: 'page', // 'page' or 'proxy'
beforeLandingPage: null,
beforeLandingPageType: 'page', // 'page' or 'proxy'
afterLandingPage: null,
afterLandingPageType: 'page', // 'page' or 'proxy'
afterLandingPageRedirectURL: null,
afterLandingPageRedirectURL: '',
email: null,
smtpConfiguration: null,
apiSender: null,
urlIdentifier: 'id',
stateIdentifier: 'session',
urlPath: null
urlPath: ''
};
let contextCompanyID = null;
@@ -97,6 +95,7 @@
id: null,
name: null
};
let showAdvancedOptions = false;
$: {
modalText = getModalText('template', modalMode);
@@ -304,26 +303,14 @@
formValues.landingPageType === 'proxy'
? landingProxyMap.byValueOrNull(formValues.landingPage)
: null,
beforeLandingPageID:
formValues.beforeLandingPageType === 'page'
? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage)
: null,
beforeLandingProxyID:
formValues.beforeLandingPageType === 'proxy'
? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage)
: null,
afterLandingPageID:
formValues.afterLandingPageType === 'page'
? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingProxyID:
formValues.afterLandingPageType === 'proxy'
? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL,
beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage),
beforeLandingProxyID: null,
afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage),
afterLandingProxyID: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '',
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
urlPath: formValues.urlPath,
urlPath: formValues.urlPath || '',
companyID: contextCompanyID
});
if (!res.success) {
@@ -356,26 +343,14 @@
formValues.landingPageType === 'proxy'
? landingProxyMap.byValueOrNull(formValues.landingPage)
: null,
beforeLandingPageID:
formValues.beforeLandingPageType === 'page'
? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage)
: null,
beforeLandingProxyID:
formValues.beforeLandingPageType === 'proxy'
? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage)
: null,
afterLandingPageID:
formValues.afterLandingPageType === 'page'
? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingProxyID:
formValues.afterLandingPageType === 'proxy'
? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL,
beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage),
beforeLandingProxyID: null,
afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage),
afterLandingProxyID: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '',
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
urlPath: formValues.urlPath
urlPath: formValues.urlPath || ''
});
if (!res.success) {
modalError = res.error;
@@ -422,24 +397,23 @@
form.reset();
formValues = {
id: null,
templateType: null,
templateType: 'Email',
name: null,
domain: null,
landingPage: null,
landingPageType: 'page',
beforeLandingPage: null,
beforeLandingPageType: 'page',
afterLandingPage: null,
afterLandingPageType: 'page',
afterLandingPageRedirectURL: null,
afterLandingPageRedirectURL: '',
email: null,
smtpConfiguration: null,
apiSender: null,
urlIdentifier: 'id',
stateIdentifier: 'session',
urlPath: null
urlPath: ''
};
modalError = '';
showAdvancedOptions = false;
};
/** @param {string} id */
@@ -488,7 +462,7 @@
if (template.smtpConfigurationID) {
formValues.templateType = 'Email';
} else {
formValues.templateType = 'External API';
formValues.templateType = 'API Sender';
}
formValues.domain = domainMap.byKey(template.domainID);
formValues.email = emailMap.byKey(template.emailID);
@@ -502,28 +476,32 @@
formValues.landingPageType = 'proxy';
}
// handle before landing page (page or proxy)
// handle before landing page (only pages, no proxy)
if (template.beforeLandingPageID) {
formValues.beforeLandingPage = beforeLandingPageMap.byKey(template.beforeLandingPageID);
formValues.beforeLandingPageType = 'page';
} else if (template.beforeLandingProxyID) {
formValues.beforeLandingPage = beforeLandingProxyMap.byKey(template.beforeLandingProxyID);
formValues.beforeLandingPageType = 'proxy';
}
// handle after landing page (page or proxy)
// handle after landing page (only pages, no proxy)
if (template.afterLandingPageID) {
formValues.afterLandingPage = afterLandingPageMap.byKey(template.afterLandingPageID);
formValues.afterLandingPageType = 'page';
} else if (template.afterLandingProxyID) {
formValues.afterLandingPage = afterLandingProxyMap.byKey(template.afterLandingProxyID);
formValues.afterLandingPageType = 'proxy';
}
formValues.afterLandingPageRedirectURL = template.afterLandingPageRedirectURL;
formValues.afterLandingPageRedirectURL = template.afterLandingPageRedirectURL || '';
formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID);
formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID);
formValues.urlPath = template.urlPath;
formValues.urlPath = template.urlPath || '';
// set advanced options visibility based on template configuration
showAdvancedOptions = !!(
(
(template.urlPath && template.urlPath !== '') ||
(template.afterLandingPageRedirectURL && template.afterLandingPageRedirectURL !== '') ||
(template.urlIdentifierID && identifierMap.byKey(template.urlIdentifierID) !== 'id') ||
(template.stateIdentifierID &&
identifierMap.byKey(template.stateIdentifierID) !== 'session') ||
template.apiSenderID
) // Show advanced if using External API
);
};
</script>
@@ -660,7 +638,7 @@
</a>
{/if}
</TableCell>
<TableCellCheck value={template.isComplete} />
<TableCellCheck value={template.isUsable} />
{#if contextCompanyID}
<TableCellScope companyID={template.companyID} />
{/if}
@@ -794,69 +772,10 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
</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
<TextField required minLength={1} maxLength={64} bind:value={formValues.name}
>Name</TextField
>
</div>
<div>
<div class="w-full">
<SelectSquare
label="Delivery Type"
options={[
{ icon: '✉️', value: 'Email', label: 'Email' },
{ icon: '🔌', value: 'External API', label: 'API' }
]}
bind:value={formValues.templateType}
/>
</div>
</div>
</div>
</div>
<!-- Delivery Configuration Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Delivery Configuration
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{#if formValues.templateType === 'Email' || !formValues.templateType}
<TextFieldSelect
id="smtpConfig"
required
bind:value={formValues.smtpConfiguration}
options={smtpConfigurationMap.values()}>SMTP Configuration</TextFieldSelect
>
{:else if formValues.templateType === 'External API'}
<TextFieldSelect
required
id="apiSender"
bind:value={formValues.apiSender}
options={apiSenderMap.values()}>API Sender</TextFieldSelect
>
{/if}
</div>
<div>
<TextFieldSelect
required
id="email"
bind:value={formValues.email}
options={emailMap.values()}>Email</TextFieldSelect
>
</div>
</div>
</div>
<!-- Domain & URL Configuration Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Domain & URL Configuration
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div>
<TextFieldSelect
required
@@ -865,32 +784,41 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
options={domainMap.values()}>Domain</TextFieldSelect
>
</div>
</div>
</div>
<!-- Delivery Configuration Section -->
<!-- Email/API Configuration -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Email Configuration
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#if formValues.templateType === 'Email' || !formValues.templateType}
<div>
<TextFieldSelect
required
id="smtpConfiguration"
bind:value={formValues.smtpConfiguration}
options={smtpConfigurationMap.values()}>SMTP Configuration</TextFieldSelect
>
</div>
{:else if formValues.templateType === 'External API'}
<div>
<TextFieldSelect
required
id="apiSender"
bind:value={formValues.apiSender}
options={apiSenderMap.values()}>API Sender</TextFieldSelect
>
</div>
{/if}
<div>
<TextField
toolTipText="Path after the domain name."
<TextFieldSelect
id="email"
bind:value={formValues.email}
optional
minLength={1}
maxLength={1024}
bind:value={formValues.urlPath}
placeholder="/employee/login">URL Path</TextField
>
</div>
<div>
<TextFieldSelect
id="urlIdentifier"
toolTipText="This is the query param key used in the phishing URL."
required
bind:value={formValues.urlIdentifier}
options={identifierMap.values()}>Query param key</TextFieldSelect
>
</div>
<div>
<TextFieldSelect
id="stateIdentifier"
toolTipText="This is the query param key used for state."
required
bind:value={formValues.stateIdentifier}
options={identifierMap.values()}>State param key</TextFieldSelect
options={emailMap.values()}>Email</TextFieldSelect
>
</div>
</div>
@@ -902,13 +830,11 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
<div class="md:col-span-2 flex flex-col space-y-6">
<!-- Before Landing Page -->
<TextFieldSelectWithType
<TextFieldSelect
id="beforeLandingPage"
bind:value={formValues.beforeLandingPage}
bind:type={formValues.beforeLandingPageType}
pageOptions={beforeLandingPageMap.values()}
proxyOptions={beforeLandingProxyMap.values()}
optional>Before Landing</TextFieldSelectWithType
options={beforeLandingPageMap.values()}
optional>Before Landing</TextFieldSelect
>
<!-- Landing Page -->
@@ -922,24 +848,12 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
<!-- After Landing Page -->
<TextFieldSelectWithType
<TextFieldSelect
id="afterLandingPage"
bind:value={formValues.afterLandingPage}
bind:type={formValues.afterLandingPageType}
pageOptions={afterLandingPageMap.values()}
proxyOptions={afterLandingProxyMap.values()}
optional>After Landing</TextFieldSelectWithType
options={afterLandingPageMap.values()}
optional>After Landing</TextFieldSelect
>
<div>
<TextField
bind:value={formValues.afterLandingPageRedirectURL}
type="url"
minLength={1}
maxLength={255}
placeholder="https://example.com/u-been-phished">POST redirect URL</TextField
>
</div>
</div>
<!-- Visualization - Takes 2 columns on larger screens -->
@@ -961,9 +875,7 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<p
class="text-xs font-medium text-gray-900 dark:text-gray-300 transition-colors duration-200"
>
Before Landing {formValues.beforeLandingPageType === 'proxy'
? 'Proxy'
: 'Page'}
Before Landing Page
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px] transition-colors duration-200"
@@ -1026,7 +938,7 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<p
class="text-xs font-medium text-gray-900 dark:text-gray-300 transition-colors duration-200"
>
After Landing {formValues.afterLandingPageType === 'proxy' ? 'Proxy' : 'Page'}
After Landing Page
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px] transition-colors duration-200"
@@ -1036,45 +948,118 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
</div>
</div>
<!-- Down Arrow -->
<div class="flex">
<div
class="ml-5 w-0.5 h-4 bg-gray-300 dark:bg-gray-600 transition-colors duration-200"
></div>
</div>
{#if showAdvancedOptions}
<!-- Down Arrow -->
<div class="flex">
<div
class="ml-5 w-0.5 h-4 bg-gray-300 dark:bg-gray-600 transition-colors duration-200"
></div>
</div>
<!-- Final Redirect -->
<div class="flex items-center">
<div
class={`w-10 h-10 rounded-lg flex items-center justify-center border mr-3 transition-colors duration-200
${formValues.afterLandingPageRedirectURL ? 'bg-blue-50 dark:bg-blue-900/40 border-blue-300 dark:border-blue-500' : 'bg-gray-100 dark:bg-gray-800/60 border-gray-300 dark:border-gray-600'}`}
>
<span
class={`text-xl transition-colors duration-200 ${formValues.afterLandingPageRedirectURL ? 'text-blue-500 dark:text-blue-300' : 'text-gray-400 dark:text-gray-500'}`}
>4</span
<!-- Final Redirect -->
<div class="flex items-center">
<div
class={`w-10 h-10 rounded-lg flex items-center justify-center border mr-3 transition-colors duration-200
${formValues.afterLandingPageRedirectURL ? 'bg-blue-50 dark:bg-blue-900/40 border-blue-300 dark:border-blue-500' : 'bg-gray-100 dark:bg-gray-800/60 border-gray-300 dark:border-gray-600'}`}
>
<span
class={`text-xl transition-colors duration-200 ${formValues.afterLandingPageRedirectURL ? 'text-blue-500 dark:text-blue-300' : 'text-gray-400 dark:text-gray-500'}`}
>4</span
>
</div>
<div class="flex-1">
<p
class="text-xs font-medium text-gray-900 dark:text-gray-300 transition-colors duration-200"
>
POST Redirect URL
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px] transition-colors duration-200"
>
{formValues.afterLandingPageRedirectURL || 'Not set'}
</p>
</div>
</div>
<div class="flex-1">
<p
class="text-xs font-medium text-gray-900 dark:text-gray-300 transition-colors duration-200"
>
POST Redirect URL
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px] transition-colors duration-200"
>
{formValues.afterLandingPageRedirectURL || 'Not set'}
</p>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<FormError message={modalError} />
</div>
<!-- Advanced Options Section -->
{#if !showAdvancedOptions}
<div class="mt-6 text-center">
<button
type="button"
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200 underline"
on:click={() => (showAdvancedOptions = true)}
>
Show advanced options
</button>
</div>
{/if}
{#if showAdvancedOptions}
<div class="w-full border-t pt-6 mt-6">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Advanced Options
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<SelectSquare
label="Delivery Type"
width="small"
center={false}
options={[
{ value: 'Email', label: 'Email' },
{ value: 'External API', label: 'External API' }
]}
bind:value={formValues.templateType}
/>
</div>
<div>
<TextField
toolTipText="Path after the domain name."
optional
minLength={1}
maxLength={1024}
bind:value={formValues.urlPath}
placeholder="/employee/login">URL Path</TextField
>
</div>
<div>
<TextFieldSelect
id="urlIdentifier"
toolTipText="This is the query param key used in the phishing URL."
required
bind:value={formValues.urlIdentifier}
options={identifierMap.values()}>Query param key</TextFieldSelect
>
</div>
<div>
<TextFieldSelect
id="stateIdentifier"
toolTipText="This is the query param key used for state."
required
bind:value={formValues.stateIdentifier}
options={identifierMap.values()}>State param key</TextFieldSelect
>
</div>
<div>
<TextField
bind:value={formValues.afterLandingPageRedirectURL}
type="url"
minLength={1}
maxLength={255}
placeholder="https://example.com/u-been-phished">POST redirect URL</TextField
>
</div>
</div>
</div>
{/if}
</div>
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
+198 -126
View File
@@ -149,6 +149,8 @@
let allowDenyType = 'none';
let allAllowDeny = [];
let showSecurityOptions = false;
let showAdvancedOptionsStep3 = false;
let showAdvancedOptionsStep4 = false;
// reactive statement to enable security options when deny page is set
$: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') {
@@ -261,6 +263,7 @@
let isSubmitting = false;
let isTableLoading = false;
let modalText = '';
let isValidatingName = false;
let weekDaysAvailable = [];
let isDeleteAlertVisible = false;
@@ -334,7 +337,7 @@
});
const nextStep = async () => {
if (validateCurrentStep()) {
if (await validateCurrentStep()) {
currentStep = Math.min(currentStep + 1, campaignSteps.length);
modalError = '';
// reset tab focus after dom update - only for explicit step navigation
@@ -372,10 +375,10 @@
}, 0);
};
const validateCurrentStep = () => {
const validateCurrentStep = async () => {
switch (currentStep) {
case 1:
return validateBasicInfo();
return await validateBasicInfo();
case 2:
return validateRecipients();
case 3:
@@ -402,8 +405,31 @@
return true;
};
const validateBasicInfo = () => {
return checkCurrentStepValidity();
const validateBasicInfo = async () => {
if (!checkCurrentStepValidity()) {
return false;
}
// check if campaign name exists
if (formValues.name?.length) {
isValidatingName = true;
try {
const nameExists = await campaignNameExists(formValues.name);
if (nameExists) {
/** @type {HTMLInputElement} */
const ele = document.querySelector('#campaignName');
if (ele) {
ele.setCustomValidity('Name is used by another campaign');
ele.reportValidity();
}
return false;
}
} finally {
isValidatingName = false;
}
}
return true;
};
const validateRecipients = () => {
@@ -733,23 +759,22 @@
};
/** @param {string} name */
const campaignNameExits = async (name) => {
const campaignNameExists = async (name) => {
if (!name?.length) return false;
try {
const res = await api.campaign.getByName(name, contextCompanyID);
/** @type {HTMLInputElement} */
const ele = document.querySelector('#campaignName');
if (
res.data &&
(modalMode === 'create' || modalMode === 'copy' || res.data.id !== formValues.id)
) {
ele.setCustomValidity('Name is used by another campaign');
ele.reportValidity();
} else {
ele.setCustomValidity('');
return true;
}
return false;
} catch (e) {
addToast('Failed to check if campaign name is used', 'Error');
console.error('Failed to check if campaign name is used', e);
return false;
}
};
@@ -795,6 +820,8 @@
allowDenyType = 'none';
spreadOption = SPREAD_MANUAL;
modalError = '';
showAdvancedOptionsStep3 = false;
showAdvancedOptionsStep4 = false;
};
const onChangeScheduleType = () => {
@@ -816,6 +843,7 @@
modalMode = null;
isModalVisible = false;
currentStep = 1;
isValidatingName = false;
if (form) form.reset();
resetFormValues();
};
@@ -868,17 +896,23 @@
name: copyMode ? `${campaign.name} (Copy)` : campaign.name,
sortField: sortField.byValue(campaign.sortField),
sortOrder: sortOrder.byValue(campaign.sortOrder),
sendStartAt: campaign.sendStartAt,
sendEndAt: campaign.sendEndAt,
scheduledStartAt: campaign.sendStartAt
? local_yyyy_mm_dd(new Date(campaign.sendStartAt))
: null,
scheduledEndAt: campaign.sendEndAt ? local_yyyy_mm_dd(new Date(campaign.sendEndAt)) : null,
constraintWeekDays: weekDayBinaryToAvailable(campaign.constraintWeekDays),
contraintStartTime: utcTimeToLocal(campaign.constraintStartTime),
contraintEndTime: utcTimeToLocal(campaign.constraintEndTime),
closeAt: campaign.closeAt,
anonymizeAt: campaign.anonymizeAt,
sendStartAt: copyMode ? null : campaign.sendStartAt,
sendEndAt: copyMode ? null : campaign.sendEndAt,
scheduledStartAt: copyMode
? null
: campaign.sendStartAt
? local_yyyy_mm_dd(new Date(campaign.sendStartAt))
: null,
scheduledEndAt: copyMode
? null
: campaign.sendEndAt
? local_yyyy_mm_dd(new Date(campaign.sendEndAt))
: null,
constraintWeekDays: copyMode ? [] : weekDayBinaryToAvailable(campaign.constraintWeekDays),
contraintStartTime: copyMode ? null : utcTimeToLocal(campaign.constraintStartTime),
contraintEndTime: copyMode ? null : utcTimeToLocal(campaign.constraintEndTime),
closeAt: copyMode ? null : campaign.closeAt,
anonymizeAt: copyMode ? null : campaign.anonymizeAt,
saveSubmittedData: campaign.saveSubmittedData,
isAnonymous: campaign.isAnonymous,
isTest: campaign.isTest,
@@ -886,19 +920,30 @@
webhookValue: webhookMap.byKey(campaign.webhookID)
};
formValues.recipientGroups = campaign.recipientGroupIDs.map((id) =>
recipientGroupMap.byKey(id)
);
formValues.selectedCount = formValues.recipientGroups.reduce((acc, label) => {
const id = recipientGroupMap.byValue(label);
const group = recipientGroupsByID[id];
return acc + group.recipientCount;
}, 0);
if (copyMode) {
// reset recipient groups when copying
formValues.recipientGroups = [];
formValues.selectedCount = 0;
} else {
formValues.recipientGroups = campaign.recipientGroupIDs.map((id) =>
recipientGroupMap.byKey(id)
);
formValues.selectedCount = formValues.recipientGroups.reduce((acc, label) => {
const id = recipientGroupMap.byValue(label);
const group = recipientGroupsByID[id];
return acc + group.recipientCount;
}, 0);
}
if (!formValues.sendStartAt && !formValues.sendEndAt) {
scheduleType = 'self-managed';
}
// reset schedule type to basic when copying since delivery times are cleared
if (copyMode) {
scheduleType = 'basic';
}
if (campaign.allowDeny.length > 0) {
allowDenyType = campaign.allowDeny[0].allowed ? 'allow' : 'deny';
} else {
@@ -911,6 +956,16 @@
formValues.denyPageValue = campaign.denyPage.name;
}
// set advanced options visibility based on campaign configuration
showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt);
showAdvancedOptionsStep4 = !!(
campaign.webhookID ||
campaign.denyPage ||
campaign.evasionPage ||
campaign.allowDeny?.length
);
if (campaign.evasionPage) {
formValues.evasionPageValue = campaign.evasionPage.name;
}
@@ -1176,25 +1231,15 @@
const ele = document.querySelector('#campaignName');
ele.setCustomValidity('');
}}
onBlur={() => {
formValues.name.length && campaignNameExits(formValues.name);
}}>Name</TextField
>
Name
</TextField>
<TextFieldSelect
required
id="template"
bind:value={formValues.template}
options={Array.from(templateMap.values())}>Template</TextFieldSelect
>
<div class="py-2">
<SelectSquare
label="Type"
width="small"
toolTipText={'Tests are not included in statistics'}
options={testOptions}
bind:value={formValues.isTest}
/>
</div>
</FormColumn>
</FormColumns>
{:else if currentStep === 2}
@@ -1318,22 +1363,6 @@
</button>
{/if}
</div>
<TextFieldSelect
id="sortField"
bind:value={formValues.sortField}
required
toolTipText="Choose which recipient field determines the delivery order"
options={Array.from(sortField.keys())}>Delivery sort by</TextFieldSelect
>
<TextFieldSelect
id="sortOrder"
bind:value={formValues.sortOrder}
toolTipText="Choose how recipients will be ordered for delivery"
required
options={Array.from(sortOrder.keys())}>Delivery sort order</TextFieldSelect
>
{:else if scheduleType === 'schedule'}
<p class="font-semibold text-slate-600 py-4">Delivery start and end</p>
<div class="flex">
@@ -1363,22 +1392,6 @@
>
</div>
<TextFieldSelect
id="sortField"
bind:value={formValues.sortField}
required
toolTipText="Choose which recipient field determines the delivery order"
options={Array.from(sortField.keys())}>Delivery by</TextFieldSelect
>
<TextFieldSelect
id="sortOrder"
bind:value={formValues.sortOrder}
toolTipText="Choose how recipients will be ordered for delivery"
required
options={Array.from(sortOrder.keys())}>Delivery order</TextFieldSelect
>
<div class="mt-4">
<p class="font-semibold text-slate-600 py-2">Delivery days</p>
<div class="grid grid-cols-4 gap-2">
@@ -1456,29 +1469,59 @@
{/if}
<div class="mt-6">
<DateTimeField
bind:value={formValues.closeAt}
min={formValues.sendEndAt
? new Date(formValues.sendEndAt)
: formValues.sendStartAt
? new Date(formValues.sendStartAt)
: new Date()}
optional
toolTipText="After this time, no more events are saved."
>Close Campaign</DateTimeField
>
{#if !showAdvancedOptionsStep3}
<div class="mt-4">
<button
type="button"
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200 underline"
on:click={() => (showAdvancedOptionsStep3 = true)}
>
Show advanced options
</button>
</div>
{/if}
<DateTimeField
bind:value={formValues.anonymizeAt}
min={formValues.closeAt
? new Date(formValues.closeAt)
: formValues.sendEndAt
{#if showAdvancedOptionsStep3}
<TextFieldSelect
id="sortField"
bind:value={formValues.sortField}
required
toolTipText="Choose which recipient field determines the delivery order"
options={Array.from(sortField.keys())}>Delivery sort by</TextFieldSelect
>
<TextFieldSelect
id="sortOrder"
bind:value={formValues.sortOrder}
toolTipText="Choose how recipients will be ordered for delivery"
required
options={Array.from(sortOrder.keys())}>Delivery order</TextFieldSelect
>
<DateTimeField
bind:value={formValues.closeAt}
min={formValues.sendEndAt
? new Date(formValues.sendEndAt)
: new Date()}
optional
toolTipText="When reached, the campaign will close and a"
>Anonymize Data</DateTimeField
>
: formValues.sendStartAt
? new Date(formValues.sendStartAt)
: new Date()}
optional
toolTipText="After this time, no more events are saved."
>Close Campaign</DateTimeField
>
<DateTimeField
bind:value={formValues.anonymizeAt}
min={formValues.closeAt
? new Date(formValues.closeAt)
: formValues.sendEndAt
? new Date(formValues.sendEndAt)
: new Date()}
optional
toolTipText="When reached, the campaign will close and a"
>Anonymize Data</DateTimeField
>
{/if}
</div>
</div>
</FormColumn>
@@ -1486,6 +1529,16 @@
{:else if currentStep === 4}
<FormColumns id={'step-4'}>
<FormColumn>
<div class="mb-6">
<SelectSquare
label="Type"
width="small"
toolTipText={'Tests are not included in statistics'}
options={testOptions}
bind:value={formValues.isTest}
/>
</div>
<div class="mb-6">
<SelectSquare
optional
@@ -1496,36 +1549,50 @@
/>
</div>
<div class="mb-6">
<TextFieldSelect
id="webhook"
bind:value={formValues.webhookValue}
optional
options={Array.from(webhookMap.values())}>Webhook</TextFieldSelect
>
</div>
{#if !showAdvancedOptionsStep4}
<div class="mt-4">
<button
type="button"
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200 underline"
on:click={() => (showAdvancedOptionsStep4 = true)}
>
Show advanced options
</button>
</div>
{/if}
<div class="mb-6">
<SelectSquare
optional
label="Security Configuration"
options={[
{ value: false, label: 'Disabled' },
{ value: true, label: 'Enabled' }
]}
bind:value={showSecurityOptions}
onChange={() => {
if (!showSecurityOptions) {
formValues.denyPageValue = '';
formValues.evasionPageValue = '';
allowDenyType = 'none';
formValues.allowDeny = [];
}
}}
/>
</div>
{#if showAdvancedOptionsStep4}
<div class="mb-6">
<TextFieldSelect
id="webhook"
bind:value={formValues.webhookValue}
optional
options={Array.from(webhookMap.values())}>Webhook</TextFieldSelect
>
</div>
{#if showSecurityOptions}
<div class="mb-6">
<SelectSquare
optional
label="Security Configuration"
options={[
{ value: false, label: 'Disabled' },
{ value: true, label: 'Enabled' }
]}
bind:value={showSecurityOptions}
onChange={() => {
if (!showSecurityOptions) {
formValues.denyPageValue = '';
formValues.evasionPageValue = '';
allowDenyType = 'none';
formValues.allowDeny = [];
}
}}
/>
</div>
{/if}
{#if showAdvancedOptionsStep4 && showSecurityOptions}
<div class="mb-6">
<TextFieldSelect
id="deny-page"
@@ -1901,10 +1968,15 @@
{#if currentStep < 5}
<button
type="button"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isValidatingName}
on:click={nextStep}
>
Next
{#if isValidatingName}
Checking...
{:else}
Next
{/if}
<svg
class="ml-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
+110
View File
@@ -63,6 +63,11 @@
name: null
};
let isIPAllowListModalVisible = false;
let ipAllowListEntries = [];
let selectedProxyForIPList = null;
let isLoadingIPAllowList = false;
const currentExample = `version: "0.0"
proxy: "My Proxy Campaign"
@@ -366,6 +371,55 @@ portal.example.com:
formValues.startURL = proxyItem.startURL;
formValues.proxyConfig = proxyItem.proxyConfig;
};
const openIPAllowListModal = async (proxy) => {
selectedProxyForIPList = proxy;
isLoadingIPAllowList = true;
isIPAllowListModalVisible = true;
console.log('Opening IP allow list for proxy:', proxy.id);
try {
const res = await api.ipAllowList.getForProxyConfig(proxy.id);
console.log('API response:', res);
if (res.success) {
ipAllowListEntries = res.data || [];
} else {
console.error('API error:', res.error);
addToast(`Failed to load IP allow list: ${res.error}`, 'Error');
ipAllowListEntries = [];
}
} catch (e) {
console.error('Network error:', e);
addToast('Failed to load IP allow list', 'Error');
ipAllowListEntries = [];
} finally {
isLoadingIPAllowList = false;
}
};
const closeIPAllowListModal = () => {
isIPAllowListModalVisible = false;
selectedProxyForIPList = null;
ipAllowListEntries = [];
};
const clearIPAllowList = async () => {
if (!selectedProxyForIPList) return;
try {
const res = await api.ipAllowList.clearForProxyConfig(selectedProxyForIPList.id);
if (res.success) {
addToast('IP allow list cleared', 'Success');
ipAllowListEntries = [];
} else {
addToast('Failed to clear IP allow list', 'Error');
}
} catch (e) {
addToast('Failed to clear IP allow list', 'Error');
console.error('failed to clear IP allow list', e);
}
};
</script>
<HeadTitle title="Proxies" />
@@ -427,6 +481,13 @@ portal.example.com:
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
/>
<TableCopyButton title={'Copy'} on:click={() => openCopyModal(proxy.id)} />
<button
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
on:click={() => openIPAllowListModal(proxy)}
title="View IP Allow List"
>
<p class="ml-2 text-left">View IP Allow List</p>
</button>
<TableDeleteButton
on:click={() => openDeleteAlert(proxy)}
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
@@ -509,4 +570,53 @@ portal.example.com:
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
<!-- IP Allow List Modal -->
<Modal
headerText={`IP Allow List - ${selectedProxyForIPList?.name || ''}`}
visible={isIPAllowListModalVisible}
onClose={closeIPAllowListModal}
isSubmitting={false}
>
<FormGrid>
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6">
<div class="flex justify-between items-center">
{#if !isLoadingIPAllowList && ipAllowListEntries && ipAllowListEntries.length > 0}
<BigButton on:click={clearIPAllowList}>Clear All</BigButton>
{/if}
</div>
{#if isLoadingIPAllowList}
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-2 text-gray-600 dark:text-gray-400">Loading...</span>
</div>
{:else if !ipAllowListEntries || ipAllowListEntries.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
No IP addresses are allow listed
</div>
{:else}
<Table
columns={[
{ column: 'IP Address', size: 'medium' },
{ column: 'Added At', size: 'medium' },
{ column: 'Expires At', size: 'medium' }
]}
hasData={ipAllowListEntries.length > 0}
plural="entries"
>
{#each ipAllowListEntries as entry}
<TableRow>
<TableCell>{entry.ip}</TableCell>
<TableCell>{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell>{new Date(entry.expiresAt).toLocaleString()}</TableCell>
<TableCellEmpty />
<TableCellEmpty />
</TableRow>
{/each}
</Table>
{/if}
</div>
</FormGrid>
</Modal>
</main>