mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-12 05:30:30 +02:00
@@ -60,7 +60,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing
|
||||
|
||||
### Blogs & Resources
|
||||
- [Covert red team phishing with Phishing Club by Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/)
|
||||
- [Whitebox vs blackbox phishing by Phishing Club](http://phishing.club/blog/white-box-vs-black-box-phishing/)
|
||||
- [Phishing Simulation vs Red Team Phishing: Understanding Different Approaches](https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/)
|
||||
|
||||
### Students & Learning
|
||||
|
||||
|
||||
+9
-3
@@ -1,14 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [1.22.0] - 2025-12-03
|
||||
## [1.23.0] - 2025-12-13
|
||||
- Added Session Sushi as recommended handling of captured cookies
|
||||
- Rename whitebox/blackbox to Simulation/Red Team
|
||||
- Added support for capturing PUT, PATCH and more content types
|
||||
- Added status modal after import recipients
|
||||
|
||||
## [1.22.0 / 1.21.1] - 2025-12-03
|
||||
- Added preview recipients modal to create campaign modal
|
||||
- Added readonly to recipient email on update modal
|
||||
- Added CTRL+s save on create modals that are primarily editors
|
||||
- Improved installer UI width
|
||||
- Added clientside validation to username and password on installer
|
||||
- Added recipient CSV clientside parsing warnings and errors
|
||||
- Added recipient CSV clientside parsing warnings and errors
|
||||
- Fix rewrite_urls query param mapping bug in proxies
|
||||
- Fix CTRL+s save on update now keep position in editor
|
||||
- Fix CTRL+s save on update now keep position in editor
|
||||
|
||||
## [1.21.0] - 2025-11-27
|
||||
- Added new capture engines for json, formdata and urlencoded
|
||||
|
||||
+158
-19
@@ -4,7 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
@@ -967,14 +970,14 @@ func (s *Server) checkAndServePhishingPage(
|
||||
nextPageType = data.PAGE_TYPE_DONE
|
||||
}
|
||||
}
|
||||
isPOSTRequest := c.Request.Method == http.MethodPost
|
||||
// if this is a POST request, then save the submitted data
|
||||
if isPOSTRequest {
|
||||
// support POST, PUT, and PATCH methods for data submission
|
||||
isDataSubmission := c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodPatch
|
||||
|
||||
// if this is a data submission request, then save the submitted data
|
||||
if isDataSubmission {
|
||||
submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
|
||||
err = c.Request.ParseForm()
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to parse submitted form data: %s", err)
|
||||
}
|
||||
newEventID := uuid.New()
|
||||
campaignID := campaign.ID.MustGet()
|
||||
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
||||
@@ -983,20 +986,156 @@ func (s *Server) checkAndServePhishingPage(
|
||||
|
||||
// prepare submitted data for webhook
|
||||
var webhookData map[string]interface{}
|
||||
var rawData string
|
||||
|
||||
if campaign.SaveSubmittedData.MustGet() {
|
||||
submittedData, err = vo.NewOptionalString1MB(c.Request.PostForm.Encode())
|
||||
// parse based on content type
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
mediaType, _, _ := mime.ParseMediaType(contentType)
|
||||
|
||||
switch {
|
||||
case strings.Contains(mediaType, "application/json"):
|
||||
// handle json content type
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to read json request body: %s", err)
|
||||
}
|
||||
c.Request.Body.Close()
|
||||
|
||||
rawData = string(body)
|
||||
|
||||
// parse json for webhook
|
||||
webhookData = make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &webhookData); err != nil {
|
||||
s.logger.Warnw("failed to parse json for webhook", "error", err)
|
||||
// store raw data if json parsing fails
|
||||
webhookData = map[string]interface{}{
|
||||
"_raw": rawData,
|
||||
}
|
||||
}
|
||||
|
||||
case strings.Contains(mediaType, "multipart/form-data"):
|
||||
// handle multipart form data
|
||||
err = c.Request.ParseMultipartForm(32 << 20) // 32 MB max
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to parse multipart form data: %s", err)
|
||||
}
|
||||
|
||||
// encode multipart data
|
||||
if c.Request.MultipartForm != nil {
|
||||
values := url.Values{}
|
||||
for key, vals := range c.Request.MultipartForm.Value {
|
||||
for _, val := range vals {
|
||||
values.Add(key, val)
|
||||
}
|
||||
}
|
||||
// include file information and content in saved data
|
||||
for key, files := range c.Request.MultipartForm.File {
|
||||
for i, file := range files {
|
||||
prefix := key
|
||||
if len(files) > 1 {
|
||||
prefix = fmt.Sprintf("%s[%d]", key, i)
|
||||
}
|
||||
values.Add(prefix+"[filename]", file.Filename)
|
||||
values.Add(prefix+"[size]", fmt.Sprintf("%d", file.Size))
|
||||
values.Add(prefix+"[content_type]", file.Header.Get("Content-Type"))
|
||||
|
||||
// read and encode file content
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
s.logger.Warnw("failed to open uploaded file", "filename", file.Filename, "error", err)
|
||||
continue
|
||||
}
|
||||
fileContent, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
s.logger.Warnw("failed to read uploaded file", "filename", file.Filename, "error", err)
|
||||
continue
|
||||
}
|
||||
// encode file content as base64
|
||||
encodedContent := base64.StdEncoding.EncodeToString(fileContent)
|
||||
values.Add(prefix+"[content]", encodedContent)
|
||||
}
|
||||
}
|
||||
rawData = values.Encode()
|
||||
|
||||
// convert to map for webhook
|
||||
webhookData = make(map[string]interface{})
|
||||
for key, vals := range c.Request.MultipartForm.Value {
|
||||
if len(vals) == 1 {
|
||||
webhookData[key] = vals[0]
|
||||
} else {
|
||||
webhookData[key] = vals
|
||||
}
|
||||
}
|
||||
// add file metadata and content for webhook
|
||||
fileData := make(map[string]interface{})
|
||||
for key, files := range c.Request.MultipartForm.File {
|
||||
fileList := make([]map[string]interface{}, 0, len(files))
|
||||
for _, file := range files {
|
||||
fileInfo := map[string]interface{}{
|
||||
"filename": file.Filename,
|
||||
"size": file.Size,
|
||||
}
|
||||
if contentType := file.Header.Get("Content-Type"); contentType != "" {
|
||||
fileInfo["content_type"] = contentType
|
||||
}
|
||||
|
||||
// read and encode file content
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
s.logger.Warnw("failed to open uploaded file for webhook", "filename", file.Filename, "error", err)
|
||||
fileList = append(fileList, fileInfo)
|
||||
continue
|
||||
}
|
||||
fileContent, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
s.logger.Warnw("failed to read uploaded file for webhook", "filename", file.Filename, "error", err)
|
||||
fileList = append(fileList, fileInfo)
|
||||
continue
|
||||
}
|
||||
// encode file content as base64
|
||||
fileInfo["content"] = base64.StdEncoding.EncodeToString(fileContent)
|
||||
fileInfo["encoding"] = "base64"
|
||||
|
||||
fileList = append(fileList, fileInfo)
|
||||
}
|
||||
if len(fileList) == 1 {
|
||||
fileData[key] = fileList[0]
|
||||
} else {
|
||||
fileData[key] = fileList
|
||||
}
|
||||
}
|
||||
if len(fileData) > 0 {
|
||||
webhookData["_files"] = fileData
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// handle url-encoded and other form data
|
||||
err = c.Request.ParseForm()
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to parse submitted form data: %s", err)
|
||||
}
|
||||
|
||||
rawData = c.Request.PostForm.Encode()
|
||||
|
||||
// convert form data to map for webhook
|
||||
webhookData = make(map[string]interface{})
|
||||
for key, values := range c.Request.PostForm {
|
||||
if len(values) == 1 {
|
||||
webhookData[key] = values[0]
|
||||
} else {
|
||||
webhookData[key] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submittedData, err = vo.NewOptionalString1MB(rawData)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("user submitted phishing data too large: %s", err)
|
||||
}
|
||||
// convert form data to map for webhook
|
||||
webhookData = make(map[string]interface{})
|
||||
for key, values := range c.Request.PostForm {
|
||||
if len(values) == 1 {
|
||||
webhookData[key] = values[0]
|
||||
} else {
|
||||
webhookData[key] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
var event *model.CampaignEvent
|
||||
// only save data if red team flag is set
|
||||
@@ -1078,8 +1217,8 @@ func (s *Server) checkAndServePhishingPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
// if redirect && POST && final page
|
||||
if isPOSTRequest {
|
||||
// if redirect && data submission && final page
|
||||
if isDataSubmission {
|
||||
if redirectURL, err := cTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
|
||||
if v := redirectURL.String(); len(v) > 0 {
|
||||
// if the current page is landing and there is no after, redirect
|
||||
|
||||
@@ -490,7 +490,7 @@ func (r *Recipient) Import(g *gin.Context) {
|
||||
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
|
||||
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
|
||||
}
|
||||
_, err := r.RecipientService.Import(
|
||||
result, err := r.RecipientService.Import(
|
||||
g,
|
||||
session,
|
||||
req.Recipients,
|
||||
@@ -500,7 +500,7 @@ func (r *Recipient) Import(g *gin.Context) {
|
||||
if ok := r.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
r.Response.OK(g, &gin.H{})
|
||||
r.Response.OK(g, result)
|
||||
}
|
||||
|
||||
// DeleteByID deletes a recipient by id
|
||||
|
||||
@@ -221,7 +221,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
|
||||
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
|
||||
}
|
||||
|
||||
err := r.RecipientGroupService.Import(
|
||||
result, err := r.RecipientGroupService.Import(
|
||||
g,
|
||||
session,
|
||||
req.Recipients,
|
||||
@@ -232,7 +232,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
|
||||
if ok := r.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
r.Response.OK(g, &gin.H{})
|
||||
r.Response.OK(g, result)
|
||||
}
|
||||
|
||||
// AddRecipients adds recipients to a recipient group
|
||||
|
||||
+13
-13
@@ -2092,13 +2092,13 @@ func (m *Proxy) validatePhishingDomainUniquenessForUpdate(ctx context.Context, p
|
||||
func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
|
||||
proxyConfig, err := proxy.ProxyConfig.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get proxy config: %w", err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to get proxy config: %w", err))
|
||||
}
|
||||
|
||||
// parse complete YAML structure
|
||||
var config ProxyServiceConfigYAML
|
||||
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
|
||||
return fmt.Errorf("failed to parse proxy config YAML: %w", err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML: %w", err))
|
||||
}
|
||||
|
||||
// set default values
|
||||
@@ -2125,7 +2125,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain)
|
||||
return errs.NewCustomError(fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain))
|
||||
}
|
||||
|
||||
// check if domain already exists (might be from previous failed attempt)
|
||||
@@ -2138,7 +2138,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err))
|
||||
}
|
||||
|
||||
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
|
||||
@@ -2164,7 +2164,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To)
|
||||
return errs.NewCustomError(fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To))
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// database error
|
||||
m.Logger.Errorw("failed to check existing domain",
|
||||
@@ -2194,7 +2194,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err))
|
||||
}
|
||||
domain.ProxyTargetDomain.Set(*proxyTargetDomain)
|
||||
|
||||
@@ -2227,7 +2227,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err))
|
||||
}
|
||||
domain.PageContent.Set(*pageContent)
|
||||
|
||||
@@ -2240,7 +2240,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err))
|
||||
}
|
||||
domain.PageNotFoundContent.Set(*pageNotFoundContent)
|
||||
|
||||
@@ -2253,7 +2253,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err))
|
||||
}
|
||||
domain.RedirectURL.Set(*redirectURL)
|
||||
|
||||
@@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
)
|
||||
// rollback created domains on error
|
||||
m.rollbackCreatedDomains(ctx, session, createdDomains)
|
||||
return fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err))
|
||||
}
|
||||
|
||||
createdDomains = append(createdDomains, domainConfig.To)
|
||||
@@ -2333,7 +2333,7 @@ func (m *Proxy) rollbackCreatedDomains(ctx context.Context, session *model.Sessi
|
||||
func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
|
||||
proxyConfig, err := proxy.ProxyConfig.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get proxy config for sync: %w", err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to get proxy config for sync: %w", err))
|
||||
}
|
||||
|
||||
// get current proxy domains by proxy ID
|
||||
@@ -2365,7 +2365,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
// parse complete YAML structure
|
||||
var config ProxyServiceConfigYAML
|
||||
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
|
||||
return fmt.Errorf("failed to parse proxy config YAML for sync: %w", err)
|
||||
return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML for sync: %w", err))
|
||||
}
|
||||
|
||||
// set default values
|
||||
@@ -2628,7 +2628,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
|
||||
if errorCount > 0 {
|
||||
errorDetails := strings.Join(syncErrors, "; ")
|
||||
return fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails)
|
||||
return errs.NewCustomError(fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+124
-22
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
@@ -495,37 +496,84 @@ func (r *Recipient) GetByEmail(
|
||||
// Import imports recipients
|
||||
// if the recipient does not exists, it will be created and added to the group
|
||||
// if the recipient exits, it will be updated and added to the group
|
||||
|
||||
// RecipientImportResult contains the results of importing recipients
|
||||
type RecipientImportResult struct {
|
||||
SuccessIDs []*uuid.UUID `json:"successIDs"`
|
||||
CreatedRecipients []RecipientImportSuccess `json:"createdRecipients"`
|
||||
UpdatedRecipients []RecipientImportSuccess `json:"updatedRecipients"`
|
||||
Failures []RecipientImportFailure `json:"failures"`
|
||||
Summary RecipientImportSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// RecipientImportSuccess contains information about a successful import
|
||||
type RecipientImportSuccess struct {
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
// RecipientImportFailure contains information about a failed import
|
||||
type RecipientImportFailure struct {
|
||||
Email string `json:"email"`
|
||||
Index int `json:"index"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RecipientImportSummary contains summary statistics
|
||||
type RecipientImportSummary struct {
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
}
|
||||
|
||||
func (r *Recipient) Import(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
recipients []*model.Recipient,
|
||||
ignoreOverwriteEmptyFields bool,
|
||||
companyID *uuid.UUID,
|
||||
) ([]*uuid.UUID, error) {
|
||||
) (*RecipientImportResult, error) {
|
||||
ae := NewAuditEvent("Recipient.Import", session)
|
||||
recipientsIDs := []*uuid.UUID{}
|
||||
result := &RecipientImportResult{
|
||||
SuccessIDs: []*uuid.UUID{},
|
||||
CreatedRecipients: []RecipientImportSuccess{},
|
||||
UpdatedRecipients: []RecipientImportSuccess{},
|
||||
Failures: []RecipientImportFailure{},
|
||||
Summary: RecipientImportSummary{
|
||||
Total: len(recipients),
|
||||
},
|
||||
}
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
r.LogAuthError(err)
|
||||
return recipientsIDs, errs.Wrap(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return recipientsIDs, errs.ErrAuthorizationFailed
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return recipientsIDs, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
|
||||
return result, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
|
||||
}
|
||||
// first validate all the entries
|
||||
for _, recipient := range recipients {
|
||||
if err := recipient.Validate(); err != nil {
|
||||
return recipientsIDs, errs.Wrap(err)
|
||||
|
||||
// process each recipient individually, collecting successes and failures
|
||||
for i, incoming := range recipients {
|
||||
// validate the recipient
|
||||
if err := incoming.Validate(); err != nil {
|
||||
result.Failures = append(result.Failures, RecipientImportFailure{
|
||||
Email: getEmailFromRecipient(incoming),
|
||||
Index: i,
|
||||
Reason: err.Error(),
|
||||
})
|
||||
result.Summary.Failed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
// if the recipient does not exist, create it
|
||||
// if the recipient exists, update it
|
||||
for _, incoming := range recipients {
|
||||
|
||||
// check if the recipient exists
|
||||
email := incoming.Email.MustGet()
|
||||
current, err := r.RecipientRepository.GetByEmail(
|
||||
@@ -534,8 +582,17 @@ func (r *Recipient) Import(
|
||||
"id", "email", "company_id",
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
r.Logger.Debugw("failed to import recipients - failed to get recipient", "error", err)
|
||||
return recipientsIDs, errs.Wrap(err)
|
||||
r.Logger.Debugw("failed to import recipient - failed to get recipient",
|
||||
"error", err,
|
||||
"email", email.String(),
|
||||
)
|
||||
result.Failures = append(result.Failures, RecipientImportFailure{
|
||||
Email: email.String(),
|
||||
Index: i,
|
||||
Reason: "database error: " + err.Error(),
|
||||
})
|
||||
result.Summary.Failed++
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
// create recipient
|
||||
@@ -548,12 +605,27 @@ func (r *Recipient) Import(
|
||||
incoming,
|
||||
)
|
||||
if err != nil {
|
||||
r.Logger.Debugw("failed to import recipients - failed to create recipient",
|
||||
r.Logger.Debugw("failed to import recipient - failed to create recipient",
|
||||
"error", err,
|
||||
"email", email.String(),
|
||||
)
|
||||
return recipientsIDs, errs.Wrap(err)
|
||||
result.Failures = append(result.Failures, RecipientImportFailure{
|
||||
Email: email.String(),
|
||||
Index: i,
|
||||
Reason: "create failed: " + err.Error(),
|
||||
})
|
||||
result.Summary.Failed++
|
||||
continue
|
||||
}
|
||||
recipientsIDs = append(recipientsIDs, recipientID)
|
||||
result.SuccessIDs = append(result.SuccessIDs, recipientID)
|
||||
result.Summary.Success++
|
||||
result.Summary.Created++
|
||||
result.CreatedRecipients = append(result.CreatedRecipients, RecipientImportSuccess{
|
||||
Email: email.String(),
|
||||
FirstName: getStringFromOptional(incoming.FirstName),
|
||||
LastName: getStringFromOptional(incoming.LastName),
|
||||
Index: i,
|
||||
})
|
||||
} else {
|
||||
// set the companyID to NOT SET, so it is not overwritten if supplied
|
||||
incoming.CompanyID.SetUnspecified()
|
||||
@@ -571,17 +643,47 @@ func (r *Recipient) Import(
|
||||
incoming,
|
||||
)
|
||||
if err != nil {
|
||||
r.Logger.Debugw("failed to import recipients - failed to update recipient",
|
||||
r.Logger.Debugw("failed to import recipient - failed to update recipient",
|
||||
"error", err,
|
||||
"email", email.String(),
|
||||
)
|
||||
return recipientsIDs, errs.Wrap(err)
|
||||
result.Failures = append(result.Failures, RecipientImportFailure{
|
||||
Email: email.String(),
|
||||
Index: i,
|
||||
Reason: "update failed: " + err.Error(),
|
||||
})
|
||||
result.Summary.Failed++
|
||||
continue
|
||||
}
|
||||
recipientsIDs = append(recipientsIDs, &recipientID)
|
||||
result.SuccessIDs = append(result.SuccessIDs, &recipientID)
|
||||
result.Summary.Success++
|
||||
result.Summary.Updated++
|
||||
result.UpdatedRecipients = append(result.UpdatedRecipients, RecipientImportSuccess{
|
||||
Email: email.String(),
|
||||
FirstName: getStringFromOptional(incoming.FirstName),
|
||||
LastName: getStringFromOptional(incoming.LastName),
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
r.AuditLogAuthorized(ae)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return recipientsIDs, nil
|
||||
// getEmailFromRecipient safely extracts email from recipient for error reporting
|
||||
func getEmailFromRecipient(r *model.Recipient) string {
|
||||
if r.Email.IsSpecified() && !r.Email.IsNull() {
|
||||
return r.Email.MustGet().String()
|
||||
}
|
||||
return "<no email>"
|
||||
}
|
||||
|
||||
// getStringFromOptional safely extracts string from optional field
|
||||
func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string {
|
||||
if field.IsSpecified() && !field.IsNull() {
|
||||
return field.MustGet().String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Delete deletes a recipient
|
||||
|
||||
@@ -92,7 +92,7 @@ func (r *RecipientGroup) Import(
|
||||
ignoreOverwriteEmptyFields bool,
|
||||
recipientGroupID *uuid.UUID,
|
||||
companyID *uuid.UUID,
|
||||
) error {
|
||||
) (*RecipientImportResult, error) {
|
||||
ae := NewAuditEvent("RecipientGroup.Import", session)
|
||||
ae.Details["recipientGroupId"] = recipientGroupID.String()
|
||||
if companyID != nil {
|
||||
@@ -102,14 +102,14 @@ func (r *RecipientGroup) Import(
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
r.LogAuthError(err)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if !isAuthorized {
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
|
||||
return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
|
||||
}
|
||||
// check that the recipient group exists
|
||||
_, err = r.RecipientGroupRepository.GetByID(
|
||||
@@ -119,9 +119,9 @@ func (r *RecipientGroup) Import(
|
||||
)
|
||||
if err != nil {
|
||||
r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
recipientIDs, err := r.RecipientService.Import(
|
||||
result, err := r.RecipientService.Import(
|
||||
ctx,
|
||||
session,
|
||||
recipients,
|
||||
@@ -129,24 +129,24 @@ func (r *RecipientGroup) Import(
|
||||
companyID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return result, err
|
||||
}
|
||||
// add recpients to group
|
||||
err = r.AddRecipients(
|
||||
ctx,
|
||||
session,
|
||||
recipientGroupID,
|
||||
recipientIDs,
|
||||
result.SuccessIDs,
|
||||
)
|
||||
if err != nil {
|
||||
r.Logger.Debugw("failed to import recipients - failed to add recipients to group",
|
||||
"error", err,
|
||||
)
|
||||
return err
|
||||
return result, err
|
||||
}
|
||||
r.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID returns a recipient group by ID
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
let isAnonymizeModalVisible = false;
|
||||
let isSendMessageModalVisible = false;
|
||||
let isSetAsSentModalVisible = false;
|
||||
let isStorageAceModalVisible = false;
|
||||
let isSessionSushiModalVisible = false;
|
||||
let storedCookieData = '';
|
||||
let sendMessageRecipient = null;
|
||||
let setAsSentRecipient = null;
|
||||
@@ -578,13 +578,13 @@
|
||||
isAnonymizeModalVisible = false;
|
||||
};
|
||||
|
||||
const closeStorageAceModal = () => {
|
||||
isStorageAceModalVisible = false;
|
||||
const closeSessionSushiModal = () => {
|
||||
isSessionSushiModalVisible = false;
|
||||
storedCookieData = '';
|
||||
};
|
||||
|
||||
const onStorageAceModalOk = () => {
|
||||
closeStorageAceModal();
|
||||
const onSessionSushiModalOk = () => {
|
||||
closeSessionSushiModal();
|
||||
};
|
||||
|
||||
/** @param {string} eventData @param {string} eventName */
|
||||
@@ -596,7 +596,7 @@
|
||||
|
||||
if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) {
|
||||
storedCookieData = eventData;
|
||||
isStorageAceModalVisible = true;
|
||||
isSessionSushiModalVisible = true;
|
||||
}
|
||||
|
||||
addToast('Copied to clipboard', 'Success');
|
||||
@@ -2169,18 +2169,18 @@
|
||||
|
||||
<Modal
|
||||
headerText={'Cookies captured'}
|
||||
visible={isStorageAceModalVisible}
|
||||
onClose={closeStorageAceModal}
|
||||
visible={isSessionSushiModalVisible}
|
||||
onClose={closeSessionSushiModal}
|
||||
>
|
||||
<div class="mt-4">
|
||||
<!-- Introduction Section -->
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-700">Import cookie</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Cookies can be imported using the <a
|
||||
href="https://chromewebstore.google.com/detail/storageace/cpbgcbmddckpmhfbdckeolkkhkjjmplo"
|
||||
Cookies can be imported and exchanged for tokens using the <a
|
||||
href="https://github.com/phishingclub/session-sushi"
|
||||
target="_blank"
|
||||
class="text-blue-600 dark:text-white hover:underline">StorageAce</a
|
||||
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
|
||||
> extension.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2203,7 +2203,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FormGrid on:submit={onStorageAceModalOk}>
|
||||
<FormGrid on:submit={onSessionSushiModalOk}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<!-- Empty form column for structure -->
|
||||
@@ -2214,7 +2214,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={closeStorageAceModal}
|
||||
on:click={closeSessionSushiModal}
|
||||
class="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -320,9 +320,6 @@
|
||||
/>
|
||||
<div class="text-left">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
|
||||
Whitebox
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block mt-1">
|
||||
Phishing Simulation
|
||||
</span>
|
||||
</div>
|
||||
@@ -341,10 +338,7 @@
|
||||
/>
|
||||
<div class="text-left">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
|
||||
Blackbox
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block mt-1">
|
||||
Red Team Phishing.
|
||||
Red Team Phishing
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
@@ -354,8 +348,8 @@
|
||||
Read about the difference between <br />
|
||||
<a
|
||||
class="white underline"
|
||||
href="https://phishing.club/blog/white-box-vs-black-box-phishing/"
|
||||
target="_blank">whitebox and blackbox phishing</a
|
||||
href="https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/"
|
||||
target="_blank">phishing simulation and red team phishing</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { getPaginatedChunkWithParams } from '$lib/service/paginationChunk';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
@@ -63,6 +64,8 @@
|
||||
ignoreOverwriteEmptyFields: true
|
||||
};
|
||||
let csvSkippedRows = [];
|
||||
let importResult = null;
|
||||
let isImportResultModalVisible = false;
|
||||
const tableImportParams = newTableParams({ sortBy: 'email' });
|
||||
let selectedRecipientsImportPaginatedChunk = [];
|
||||
let isImportModalVisible = false;
|
||||
@@ -210,8 +213,29 @@
|
||||
importModalError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Recipients imported', 'Success');
|
||||
|
||||
// store import result for display
|
||||
importResult = res.data;
|
||||
|
||||
// build summary message
|
||||
const summary = res.data.summary;
|
||||
let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
|
||||
if (summary.failed > 0) {
|
||||
message += `, ${summary.failed} failed`;
|
||||
}
|
||||
if (csvSkippedRows.length > 0) {
|
||||
message += `, ${csvSkippedRows.length} skipped in CSV`;
|
||||
}
|
||||
|
||||
console.log(summary);
|
||||
addToast(
|
||||
'Import finished',
|
||||
summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
|
||||
);
|
||||
|
||||
// show result modal
|
||||
closeImportModal();
|
||||
isImportResultModalVisible = true;
|
||||
refreshRecipients();
|
||||
} catch (err) {
|
||||
addToast('Failed to import recipients', 'Error');
|
||||
@@ -289,6 +313,7 @@
|
||||
const openImportModal = () => {
|
||||
csvSkippedRows = [];
|
||||
importModalError = '';
|
||||
importResult = null;
|
||||
isImportModalVisible = true;
|
||||
};
|
||||
|
||||
@@ -685,4 +710,232 @@
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
></DeleteAlert>
|
||||
|
||||
{#if isImportResultModalVisible && importResult}
|
||||
<Modal headerText="Recipient Import Summary" bind:visible={isImportResultModalVisible}>
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Statistics Section -->
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Recipients</h3>
|
||||
<ul class="space-y-1">
|
||||
<li>Total: {importResult.summary.total}</li>
|
||||
<li>Created: {importResult.summary.created}</li>
|
||||
<li>Updated: {importResult.summary.updated}</li>
|
||||
<li>Failed: {importResult.summary.failed}</li>
|
||||
{#if csvSkippedRows.length > 0}
|
||||
<li>Skipped in CSV: {csvSkippedRows.length}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Section -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="space-y-4">
|
||||
{#if importResult.createdRecipients?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Created ({importResult.createdRecipients.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="space-y-1">
|
||||
{#each importResult.createdRecipients as recipient}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{recipient.email}
|
||||
</span>
|
||||
{#if recipient.firstName || recipient.lastName}
|
||||
<span
|
||||
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
|
||||
>
|
||||
{recipient.firstName || ''}
|
||||
{recipient.lastName || ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.updatedRecipients?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Updated ({importResult.updatedRecipients.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="space-y-1">
|
||||
{#each importResult.updatedRecipients as recipient}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{recipient.email}
|
||||
</span>
|
||||
{#if recipient.firstName || recipient.lastName}
|
||||
<span
|
||||
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
|
||||
>
|
||||
{recipient.firstName || ''}
|
||||
{recipient.lastName || ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if csvSkippedRows.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Skipped in CSV ({csvSkippedRows.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
These rows were skipped during CSV parsing (before import)
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
{#each csvSkippedRows as skip}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
Line {skip.line}: {skip.reason}
|
||||
{#if skip.row?.email}
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-2"
|
||||
>({skip.row.email})</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.failures?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Import Errors ({importResult.failures.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
These recipients failed to import (backend errors)
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
{#each importResult.failures as err}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{err.email}: {err.reason}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button on:click={() => (isImportResultModalVisible = false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { parseCSVToRecipients } from '$lib/utils/csv';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
@@ -62,6 +63,8 @@
|
||||
ignoreOverwriteEmptyFields: true
|
||||
};
|
||||
let csvSkippedRows = [];
|
||||
let importResult = null;
|
||||
let isImportResultModalVisible = false;
|
||||
const tableImportParams = newTableParams({ sortBy: 'email' });
|
||||
let selectedRecipientsImportPaginatedChunk = [];
|
||||
let isImportModalVisible = false;
|
||||
@@ -264,8 +267,29 @@
|
||||
importError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Recipients imported to group', 'Success');
|
||||
|
||||
// store import result for display
|
||||
importResult = res.data;
|
||||
|
||||
// build summary message
|
||||
const summary = res.data.summary;
|
||||
let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
|
||||
if (summary.failed > 0) {
|
||||
message += `, ${summary.failed} failed`;
|
||||
}
|
||||
if (csvSkippedRows.length > 0) {
|
||||
message += `, ${csvSkippedRows.length} skipped in CSV`;
|
||||
}
|
||||
|
||||
console.log(summary);
|
||||
addToast(
|
||||
'Import finished',
|
||||
summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
|
||||
);
|
||||
|
||||
// show result modal
|
||||
closeImportModal();
|
||||
isImportResultModalVisible = true;
|
||||
refreshRecipients();
|
||||
} catch (err) {
|
||||
addToast('Failed to import recipients to group', 'Error');
|
||||
@@ -286,6 +310,7 @@
|
||||
|
||||
// track skipped rows
|
||||
if (result.skipped && result.skipped.length > 0) {
|
||||
csvSkippedRows = csvSkippedRows.concat(result.skipped);
|
||||
console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped);
|
||||
}
|
||||
|
||||
@@ -353,6 +378,7 @@
|
||||
const openImportModal = () => {
|
||||
csvSkippedRows = [];
|
||||
importError = '';
|
||||
importResult = null;
|
||||
isImportModalVisible = true;
|
||||
};
|
||||
|
||||
@@ -581,4 +607,232 @@
|
||||
onClick={() => onClickRemoveRecipient(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
></DeleteAlert>
|
||||
|
||||
{#if isImportResultModalVisible && importResult}
|
||||
<Modal headerText="Recipient Import Summary" bind:visible={isImportResultModalVisible}>
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Statistics Section -->
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Recipients</h3>
|
||||
<ul class="space-y-1">
|
||||
<li>Total: {importResult.summary.total}</li>
|
||||
<li>Created: {importResult.summary.created}</li>
|
||||
<li>Updated: {importResult.summary.updated}</li>
|
||||
<li>Failed: {importResult.summary.failed}</li>
|
||||
{#if csvSkippedRows.length > 0}
|
||||
<li>Skipped in CSV: {csvSkippedRows.length}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Section -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="space-y-4">
|
||||
{#if importResult.createdRecipients?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Created ({importResult.createdRecipients.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="space-y-1">
|
||||
{#each importResult.createdRecipients as recipient}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{recipient.email}
|
||||
</span>
|
||||
{#if recipient.firstName || recipient.lastName}
|
||||
<span
|
||||
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
|
||||
>
|
||||
{recipient.firstName || ''}
|
||||
{recipient.lastName || ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.updatedRecipients?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Updated ({importResult.updatedRecipients.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="space-y-1">
|
||||
{#each importResult.updatedRecipients as recipient}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{recipient.email}
|
||||
</span>
|
||||
{#if recipient.firstName || recipient.lastName}
|
||||
<span
|
||||
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
|
||||
>
|
||||
{recipient.firstName || ''}
|
||||
{recipient.lastName || ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if csvSkippedRows.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Skipped in CSV ({csvSkippedRows.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
These rows were skipped during CSV parsing (before import)
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
{#each csvSkippedRows as skip}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
Line {skip.line}: {skip.reason}
|
||||
{#if skip.row?.email}
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-2"
|
||||
>({skip.row.email})</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.failures?.length > 0}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Import Errors ({importResult.failures.length})</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
These recipients failed to import (backend errors)
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
{#each importResult.failures as err}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
|
||||
>
|
||||
{err.email}: {err.reason}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button on:click={() => (isImportResultModalVisible = false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -726,10 +726,7 @@
|
||||
/>
|
||||
<div class="text-left flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
|
||||
Whitebox Mode
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block mt-1">
|
||||
For Phishing Simulation
|
||||
Phishing Simulation
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
@@ -747,18 +744,15 @@
|
||||
/>
|
||||
<div class="text-left flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
|
||||
Blackbox Mode
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block mt-1">
|
||||
For Red Teaming
|
||||
Red Team Phishing
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Read about the difference between <a
|
||||
class="white underline"
|
||||
href="https://phishing.club/blog/white-box-vs-black-box-phishing/"
|
||||
target="_blank">whitebox and blackbox phishing</a
|
||||
href="https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/"
|
||||
target="_blank">phishing simulation and red team phishing</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user