mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-05-26 01:27:49 +02:00
change access directive and add management for proxy allow list
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user