mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-08 21:38:15 +02:00
Add proxy support in device codes
Add clear device codes for campaign
This commit is contained in:
@@ -154,6 +154,7 @@ const (
|
||||
ROUTE_V1_CAMPAIGN_STATS_UPDATE = "/api/v1/campaign/stats/:id"
|
||||
ROUTE_V1_CAMPAIGN_STATS_DELETE = "/api/v1/campaign/stats/:id"
|
||||
ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported"
|
||||
ROUTE_V1_CAMPAIGN_DEVICE_CODES = "/api/v1/campaign/:id/device-codes"
|
||||
// campaign-recipient
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
|
||||
@@ -431,6 +432,7 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV).
|
||||
POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV).
|
||||
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_DEVICE_CODES, middleware.SessionHandler, controllers.Campaign.DeleteDeviceCodesByCampaignID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
|
||||
// campaign-recipient
|
||||
GET(ROUTE_V1_CAMPAIGN_RECIPIENTS, middleware.SessionHandler, controllers.Campaign.GetRecipientsByCampaignID).
|
||||
|
||||
@@ -432,7 +432,7 @@ func (s *Server) Handler(c *gin.Context) {
|
||||
isRequestForPhishingPageOrDenied, err := s.checkAndServePhishingPage(c, domain)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to serve phishing page",
|
||||
"error", err,
|
||||
"error", utils.RedactCredentialsFromString(err.Error()),
|
||||
)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
c.Abort()
|
||||
|
||||
@@ -80,6 +80,23 @@ type Campaign struct {
|
||||
}
|
||||
|
||||
// CloseCampaignByID closes campaign
|
||||
// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign
|
||||
func (c *Campaign) DeleteDeviceCodesByCampaignID(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := c.CampaignService.DeleteDeviceCodesByCampaignID(g.Request.Context(), session, id)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
|
||||
@@ -23,6 +23,11 @@ type MicrosoftDeviceCode struct {
|
||||
VerificationURI string `gorm:"not null;"`
|
||||
ExpiresAt *time.Time `gorm:"not null;index;"`
|
||||
|
||||
// ProxyURL is an optional proxy URL used for outbound requests
|
||||
// supports http, https, socks4, socks5 and user:pass@host:port formats
|
||||
// empty string means no proxy is used
|
||||
ProxyURL string `gorm:"not null;default:''"`
|
||||
|
||||
// last_polled_at is nil when the entry has never been polled
|
||||
LastPolledAt *time.Time `gorm:"index;"`
|
||||
|
||||
@@ -61,5 +66,6 @@ func (MicrosoftDeviceCode) Migrate(db *gorm.DB) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ type MicrosoftDeviceCode struct {
|
||||
// defaults to true so that page refreshes after capture do not generate a new device code.
|
||||
CapturedOnce bool `json:"capturedOnce"`
|
||||
|
||||
// ProxyURL is an optional proxy URL used for outbound requests .
|
||||
// supports http, https, socks4, socks5 and user:pass@host:port formats.
|
||||
// empty string means no proxy is used.
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
|
||||
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"`
|
||||
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"`
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func (r *MicrosoftDeviceCode) Insert(
|
||||
"id_token": "",
|
||||
"captured": false,
|
||||
"captured_once": entry.CapturedOnce,
|
||||
"proxy_url": entry.ProxyURL,
|
||||
}
|
||||
if v, err := entry.CampaignID.Get(); err == nil {
|
||||
row["campaign_id"] = v.String()
|
||||
@@ -170,6 +171,7 @@ func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDe
|
||||
IDToken: row.IDToken,
|
||||
Captured: row.Captured,
|
||||
CapturedOnce: row.CapturedOnce,
|
||||
ProxyURL: row.ProxyURL,
|
||||
CampaignID: campaignID,
|
||||
RecipientID: recipientID,
|
||||
}
|
||||
|
||||
@@ -2879,6 +2879,36 @@ func (c *Campaign) HandleAnonymizeCampaigns(
|
||||
}
|
||||
|
||||
// CloseCampaignByID closes a campaign by id
|
||||
// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign so every recipient
|
||||
// gets a fresh code (and picks up any proxy change) on their next page visit.
|
||||
func (c *Campaign) DeleteDeviceCodesByCampaignID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("Campaign.DeleteDeviceCodesByCampaignID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return errs.Wrap(errs.ErrAuthorizationFailed)
|
||||
}
|
||||
if c.MicrosoftDeviceCodeRepository == nil {
|
||||
return nil
|
||||
}
|
||||
if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil {
|
||||
c.Logger.Errorw("failed to delete device codes for campaign", "error", err, "campaignID", id.String())
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Campaign) CloseCampaignByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
@@ -65,6 +66,10 @@ type MicrosoftDeviceCodeOptions struct {
|
||||
// GetOrCreateDeviceCode calls instead of being replaced with a fresh code.
|
||||
// nil means unset — applyDeviceCodeDefaults will default it to true.
|
||||
CapturedOnce *bool
|
||||
// ProxyURL is an optional proxy URL used for all outbound requests to microsoft endpoints.
|
||||
// supports http, https, socks4, socks5 and user:pass@host:port formats.
|
||||
// empty string means no proxy is used.
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
// tenantIDPattern matches valid microsoft tenant identifiers:
|
||||
@@ -133,15 +138,101 @@ type microsoftTokenErrorResponse struct {
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// httpClientWithProxy returns an *http.Client configured with the given proxy URL string.
|
||||
// if proxyURL is empty, s.HTTPClient is returned unchanged.
|
||||
// a new transport is built each time so there is no risk of stale connections if a proxy is rotated.
|
||||
func (s *MicrosoftDeviceCode) httpClientWithProxy(proxyURL string) (*http.Client, error) {
|
||||
if proxyURL == "" {
|
||||
return s.HTTPClient, nil
|
||||
}
|
||||
parsed, err := parseDeviceCodeProxyURL(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL %q: %w", redactProxyURL(proxyURL), err)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: s.HTTPClient.Timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDeviceCodeProxyURL parses and normalises a proxy URL string.
|
||||
// if the string has no scheme, it prepends "http://" to support bare host:port,
|
||||
// user:pass@host:port, and socks4/socks5 strings that already carry a scheme.
|
||||
func parseDeviceCodeProxyURL(proxyStr string) (*url.URL, error) {
|
||||
if !strings.Contains(proxyStr, "://") {
|
||||
proxyStr = "http://" + proxyStr
|
||||
}
|
||||
return url.Parse(proxyStr)
|
||||
}
|
||||
|
||||
// redactProxyURL returns a copy of proxyURL with any userinfo (username and/or password)
|
||||
// removed so the result is safe to include in logs and event data.
|
||||
// if parsing fails the host portion is returned as-is without credentials.
|
||||
// e.g. "socks5://user:pass@10.0.0.1:1080" → "socks5://10.0.0.1:1080"
|
||||
func redactProxyURL(proxyURL string) string {
|
||||
if proxyURL == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := parseDeviceCodeProxyURL(proxyURL)
|
||||
if err != nil {
|
||||
// best-effort: strip everything before the last '@' if present
|
||||
if idx := strings.LastIndex(proxyURL, "@"); idx != -1 && utf8.ValidString(proxyURL[idx+1:]) {
|
||||
return proxyURL[idx+1:]
|
||||
}
|
||||
return "(unparseable proxy url)"
|
||||
}
|
||||
parsed.User = nil
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// isProxyConnectionError returns true when err looks like a transport-level connection failure
|
||||
// (dial refused, connection reset, i/o timeout, etc.) rather than a well-formed HTTP/API error
|
||||
// from the upstream server. it is used to distinguish "cannot reach proxy" from "microsoft
|
||||
// returned an error response".
|
||||
func isProxyConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
keywords := []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"no such host",
|
||||
"network is unreachable",
|
||||
"dial tcp",
|
||||
"dial udp",
|
||||
"i/o timeout",
|
||||
"eof",
|
||||
"proxy",
|
||||
"socks",
|
||||
}
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(msg, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requestDeviceCode calls microsoft's device code endpoint and returns the parsed response
|
||||
func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions) (*microsoftDeviceCodeResponse, error) {
|
||||
client, err := s.httpClientWithProxy(opts.ProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device code request: failed to build http client: %w", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf(microsoftDeviceCodeEndpoint, opts.TenantID)
|
||||
form := url.Values{
|
||||
"client_id": {opts.ClientID},
|
||||
"scope": {opts.Scope},
|
||||
}
|
||||
resp, err := s.HTTPClient.PostForm(endpoint, form)
|
||||
resp, err := client.PostForm(endpoint, form)
|
||||
if err != nil {
|
||||
if opts.ProxyURL != "" && isProxyConnectionError(err) {
|
||||
return nil, fmt.Errorf("device code request: proxy connection failed (%s): %w", redactProxyURL(opts.ProxyURL), err)
|
||||
}
|
||||
return nil, fmt.Errorf("device code request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -166,44 +257,53 @@ func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions
|
||||
// returns (tokenResponse, isPending, error).
|
||||
// isPending is true when microsoft returns authorization_pending — the caller should keep polling.
|
||||
// any other error means polling should stop for this code.
|
||||
func (s *MicrosoftDeviceCode) pollTokenEndpoint(tenantID, clientID, deviceCode string) (*microsoftTokenResponse, bool, error) {
|
||||
endpoint := fmt.Sprintf(microsoftTokenEndpoint, tenantID)
|
||||
// proxyConnectionErr is true when the failure is a transport-level dial/connect failure against
|
||||
// the configured proxy rather than a response from the microsoft endpoint.
|
||||
func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode) (tokenResp *microsoftTokenResponse, isPending bool, proxyConnErr bool, err error) {
|
||||
client, buildErr := s.httpClientWithProxy(entry.ProxyURL)
|
||||
if buildErr != nil {
|
||||
return nil, false, false, fmt.Errorf("poll token: failed to build http client: %w", buildErr)
|
||||
}
|
||||
endpoint := fmt.Sprintf(microsoftTokenEndpoint, entry.TenantID)
|
||||
form := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceCode},
|
||||
"client_id": {entry.ClientID},
|
||||
"device_code": {entry.DeviceCode},
|
||||
}
|
||||
resp, err := s.HTTPClient.PostForm(endpoint, form)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("token poll request failed: %w", err)
|
||||
resp, postErr := client.PostForm(endpoint, form)
|
||||
if postErr != nil {
|
||||
if entry.ProxyURL != "" && isProxyConnectionError(postErr) {
|
||||
return nil, false, true, fmt.Errorf("token poll: proxy connection failed (%s): %w", redactProxyURL(entry.ProxyURL), postErr)
|
||||
}
|
||||
return nil, false, false, fmt.Errorf("token poll request failed: %w", postErr)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to read token poll response body: %w", err)
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return nil, false, false, fmt.Errorf("failed to read token poll response body: %w", readErr)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var tokenResp microsoftTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to parse token response: %w", err)
|
||||
var tr microsoftTokenResponse
|
||||
if jsonErr := json.Unmarshal(body, &tr); jsonErr != nil {
|
||||
return nil, false, false, fmt.Errorf("failed to parse token response: %w", jsonErr)
|
||||
}
|
||||
return &tokenResp, false, nil
|
||||
return &tr, false, false, nil
|
||||
}
|
||||
|
||||
// non-200 — check for authorization_pending vs terminal errors
|
||||
var errResp microsoftTokenErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return nil, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body))
|
||||
if jsonErr := json.Unmarshal(body, &errResp); jsonErr != nil {
|
||||
return nil, false, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if errResp.Error == errAuthorizationPending {
|
||||
return nil, true, nil
|
||||
return nil, true, false, nil
|
||||
}
|
||||
|
||||
// any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal
|
||||
return nil, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription)
|
||||
return nil, false, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription)
|
||||
}
|
||||
|
||||
// GetOrCreateDeviceCode returns an existing valid (non-expired, non-captured) device code for the
|
||||
@@ -247,6 +347,16 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
// request a fresh device code from microsoft
|
||||
dcResp, err := s.requestDeviceCode(&opts)
|
||||
if err != nil {
|
||||
if opts.ProxyURL != "" && isProxyConnectionError(err) {
|
||||
// log at error level with redacted proxy — never include raw proxy URL (may contain credentials)
|
||||
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(opts.ProxyURL))
|
||||
s.Logger.Errorw("device code creation: proxy connection error",
|
||||
"error", safeMsg,
|
||||
"proxy", redactProxyURL(opts.ProxyURL),
|
||||
)
|
||||
s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, "", "", safeMsg)
|
||||
return nil, errs.Wrap(fmt.Errorf("%s", safeMsg))
|
||||
}
|
||||
s.Logger.Errorw("failed to request device code from microsoft", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
@@ -267,6 +377,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
Scope: opts.Scope,
|
||||
Captured: false,
|
||||
CapturedOnce: *opts.CapturedOnce,
|
||||
ProxyURL: opts.ProxyURL,
|
||||
CampaignID: campaignIDNullable,
|
||||
RecipientID: recipientIDNullable,
|
||||
}
|
||||
@@ -380,8 +491,25 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
)
|
||||
}
|
||||
|
||||
tokenResp, isPending, err := s.pollTokenEndpoint(entry.TenantID, entry.ClientID, entry.DeviceCode)
|
||||
tokenResp, isPending, proxyConnErr, err := s.pollTokenEndpoint(entry)
|
||||
if err != nil {
|
||||
if proxyConnErr {
|
||||
// proxy connection failure — log at error level so operators can see it, and save
|
||||
// a campaign info event with a sanitised message (no credentials).
|
||||
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(entry.ProxyURL))
|
||||
s.Logger.Errorw("device code poll: proxy connection error",
|
||||
"error", safeMsg,
|
||||
"proxy", redactProxyURL(entry.ProxyURL),
|
||||
"userCode", entry.UserCode,
|
||||
"deviceCodeID", entryID,
|
||||
)
|
||||
campaignID, cidErr := entry.CampaignID.Get()
|
||||
recipientID, ridErr := entry.RecipientID.Get()
|
||||
if cidErr == nil && ridErr == nil {
|
||||
s.saveDeviceCodeCreatedEvent(ctx, &campaignID, &recipientID, entry.UserCode, entry.VerificationURI, safeMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// terminal error from microsoft — log at debug level since this is expected for
|
||||
// denied/expired codes and we don't want to spam the error logs
|
||||
s.Logger.Debugw("device code polling returned terminal error",
|
||||
|
||||
@@ -98,7 +98,7 @@ func (t *Template) ValidatePageTemplate(content string) error {
|
||||
// also try to execute with mock data to catch runtime errors
|
||||
_, err = t.ApplyPageMock(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute page template: %s", err)
|
||||
return fmt.Errorf("failed to execute page template: %s", utils.RedactCredentialsFromString(err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -476,7 +476,7 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
return w, fmt.Errorf("failed to execute page template: %s", err)
|
||||
return w, fmt.Errorf("failed to execute page template: %s", utils.RedactCredentialsFromString(err.Error()))
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
@@ -703,6 +703,8 @@ func (t *Template) TemplateFuncsWithDeviceCode(
|
||||
case "capturedOnce":
|
||||
v := args[i+1] != "false"
|
||||
opts.CapturedOnce = &v
|
||||
case "proxyUrl":
|
||||
opts.ProxyURL = args[i+1]
|
||||
}
|
||||
}
|
||||
return opts
|
||||
|
||||
@@ -2,11 +2,23 @@ package utils
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
)
|
||||
|
||||
// credentialsPattern matches userinfo (user, or user:pass) inside a URL authority component.
|
||||
// it captures scheme://user:pass@host and scheme://user@host forms.
|
||||
var credentialsPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+\-.]*://)([^@/\s]+@)`)
|
||||
|
||||
// RedactCredentialsFromString replaces any embedded URL credentials (user:pass@ or user@) found
|
||||
// in s with the scheme and host only, so the result is safe to write to logs or error messages.
|
||||
// e.g. "socks5://proxyuser:SecretDino123@10.0.0.1:1080" → "socks5://10.0.0.1:1080"
|
||||
func RedactCredentialsFromString(s string) string {
|
||||
return credentialsPattern.ReplaceAllString(s, "$1")
|
||||
}
|
||||
|
||||
// Substring returns a substring of the input text from the start index to the end index.
|
||||
// If the start index is less than 0, it is set to 0.
|
||||
// If the end index is greater than the length of the text, it is set to the length of the text.
|
||||
|
||||
@@ -881,6 +881,17 @@ export class API {
|
||||
return await getJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/url`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all device codes for a campaign so every recipient gets a fresh
|
||||
* code (and picks up any proxy change) on their next page visit.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
deleteDeviceCodes: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/campaign/${id}/device-codes`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a campaign.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import { toEvent } from '$lib/utils/events';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TestLabel from '$lib/components/TestLabel.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
@@ -400,6 +401,11 @@
|
||||
let isValidatingName = false;
|
||||
let weekDaysAvailable = [];
|
||||
let isDeleteAlertVisible = false;
|
||||
let isClearDeviceCodesAlertVisible = false;
|
||||
let clearDeviceCodesValues = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
$: {
|
||||
modalText = getModalText('campaign', modalMode);
|
||||
@@ -900,6 +906,28 @@
|
||||
deleteValues.name = campaign.name;
|
||||
};
|
||||
|
||||
const openClearDeviceCodesAlert = (campaign) => {
|
||||
isClearDeviceCodesAlertVisible = true;
|
||||
clearDeviceCodesValues.id = campaign.id;
|
||||
clearDeviceCodesValues.name = campaign.name;
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
const onClickClearDeviceCodes = async (id) => {
|
||||
try {
|
||||
const res = await api.campaign.deleteDeviceCodes(id);
|
||||
if (res.success) {
|
||||
addToast('Device codes cleared', 'Success');
|
||||
} else {
|
||||
addToast('Failed to clear device codes', 'Error');
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
addToast('Failed to clear device codes', 'Error');
|
||||
console.error('failed to clear device codes:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickDelete = async (id) => {
|
||||
const action = api.campaign.delete(id);
|
||||
console.log(action);
|
||||
@@ -1454,6 +1482,13 @@
|
||||
on:click={() => openDeleteAlert(campaign)}
|
||||
{...globalButtonDisabledAttributes(campaign, contextCompanyID)}
|
||||
/>
|
||||
<ConditionalDisplay show="blackbox">
|
||||
<TableDropDownButton
|
||||
name="Clear device codes"
|
||||
on:click={() => openClearDeviceCodesAlert(campaign)}
|
||||
{...globalButtonDisabledAttributes(campaign, contextCompanyID)}
|
||||
/>
|
||||
</ConditionalDisplay>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
@@ -2662,4 +2697,13 @@
|
||||
confirm
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
/>
|
||||
<DeleteAlert
|
||||
title="Clear device codes"
|
||||
list={['All active device codes for this campaign will be deleted']}
|
||||
name={clearDeviceCodesValues.name}
|
||||
actionMessage="Clear all device codes for"
|
||||
permanent={false}
|
||||
onClick={() => onClickClearDeviceCodes(clearDeviceCodesValues.id)}
|
||||
bind:isVisible={isClearDeviceCodesAlertVisible}
|
||||
/>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user