mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-12 10:07:14 +02:00
added awareness training campaign mode, report, template mode, and stats
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
1 parent
6804710d3c
commit
a7fbd573f6
37 files changed
+1540
-233
No files matched your search
@@ -1130,7 +1130,8 @@ export class API {
|
||||
urlPath: urlPath,
|
||||
lureURLMode,
|
||||
lureCodeAlgo,
|
||||
lureCodeLength
|
||||
lureCodeLength,
|
||||
isTraining
|
||||
}) => {
|
||||
return await postJSON(this.getPath('/campaign/template'), {
|
||||
name: name,
|
||||
@@ -1151,7 +1152,8 @@ export class API {
|
||||
urlPath: urlPath,
|
||||
lureURLMode: lureURLMode,
|
||||
lureCodeAlgo: lureCodeAlgo,
|
||||
lureCodeLength: lureCodeLength
|
||||
lureCodeLength: lureCodeLength,
|
||||
isTraining: isTraining
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1201,7 +1203,8 @@ export class API {
|
||||
urlPath: urlPath,
|
||||
lureURLMode,
|
||||
lureCodeAlgo,
|
||||
lureCodeLength
|
||||
lureCodeLength,
|
||||
isTraining
|
||||
}) => {
|
||||
return await postJSON(this.getPath(`/campaign/template/${id}`), {
|
||||
name: name,
|
||||
@@ -1222,7 +1225,8 @@ export class API {
|
||||
urlPath: urlPath,
|
||||
lureURLMode: lureURLMode,
|
||||
lureCodeAlgo: lureCodeAlgo,
|
||||
lureCodeLength: lureCodeLength
|
||||
lureCodeLength: lureCodeLength,
|
||||
isTraining: isTraining
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -497,7 +497,8 @@
|
||||
? `${formatDateString(new Date(campaign.createdAt))} - ?`
|
||||
: `${formatDateString(campaign.start)} - ${formatDateString(campaign.end)}`;
|
||||
|
||||
let tooltip = `${campaign.name} - ${status}, ${dateRange}`;
|
||||
const kind = campaign.isTraining ? 'Training - ' : '';
|
||||
let tooltip = `${campaign.name} - ${kind}${status}, ${dateRange}`;
|
||||
|
||||
if (showCompany && campaign.company?.name) {
|
||||
tooltip = `[${campaign.company.name}] ${tooltip}`;
|
||||
@@ -685,6 +686,19 @@
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<span class="flex items-center text-xs text-gray-700 dark:text-gray-300">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 mr-1 text-training-completed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
|
||||
/>
|
||||
</svg>
|
||||
Training
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -832,9 +846,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="campaign-name truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
|
||||
class="campaign-name flex items-center gap-1 truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
|
||||
>
|
||||
{truncateText(campaign.name, 24)}
|
||||
{#if campaign.isTraining}
|
||||
<svg
|
||||
class="flex-shrink-0 h-3 w-3 text-training-completed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label="Training"
|
||||
>
|
||||
<path
|
||||
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="truncate">{truncateText(campaign.name, 24)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
const MOVING_AVG_N_KEY = 'campaignTrend.movingAvgN';
|
||||
const LOG_SCALE_KEY = 'campaignTrend.useLogScale';
|
||||
const RELATIVE_METRICS_KEY = 'campaignTrend.useRelativeMetrics';
|
||||
const CHART_MODE_KEY = 'campaignTrend.chartMode';
|
||||
|
||||
let chartContainer;
|
||||
let sizingContainer;
|
||||
@@ -204,6 +205,9 @@
|
||||
let xScale, yScale;
|
||||
let useLogScale = false;
|
||||
let useRelativeMetrics = false;
|
||||
// 'phishing' shows the read/click/submission/report funnel, 'training' shows the
|
||||
// awareness training funnel. Only offered when training campaigns exist.
|
||||
let chartMode = 'phishing';
|
||||
|
||||
// load saved settings from localStorage
|
||||
try {
|
||||
@@ -224,6 +228,26 @@
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
try {
|
||||
const storedChartMode = localStorage.getItem(CHART_MODE_KEY);
|
||||
if (storedChartMode === 'training' || storedChartMode === 'phishing') {
|
||||
chartMode = storedChartMode;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
// the training mode is only meaningful when there are training campaigns
|
||||
$: hasTraining = (campaignStats || []).some((s) => s.isTraining);
|
||||
$: if (!hasTraining && chartMode !== 'phishing') {
|
||||
chartMode = 'phishing';
|
||||
}
|
||||
$: try {
|
||||
localStorage.setItem(CHART_MODE_KEY, chartMode);
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
// Time range filter for campaigns
|
||||
const timeRanges = [
|
||||
{ label: 'Last 3 months', value: '3' },
|
||||
@@ -261,12 +285,48 @@
|
||||
});
|
||||
})();
|
||||
|
||||
const metrics = [
|
||||
// a metric with a `mavg` also renders a dashed moving-average line and legend toggle
|
||||
const phishingMetrics = [
|
||||
{ key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' },
|
||||
{ key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' },
|
||||
{ key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' },
|
||||
{ key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' }
|
||||
{
|
||||
key: 'clickRate',
|
||||
label: 'Click Rate',
|
||||
color: '#f96dcf',
|
||||
suffix: '%',
|
||||
mavg: { color: '#eea5fa', label: 'Click MA' }
|
||||
},
|
||||
{
|
||||
key: 'submissionRate',
|
||||
label: 'Submission Rate',
|
||||
color: '#f42e41',
|
||||
suffix: '%',
|
||||
mavg: { color: '#ff6a91', label: 'Submit MA' }
|
||||
},
|
||||
{
|
||||
key: 'reportRate',
|
||||
label: 'Report Rate',
|
||||
color: '#1e40af',
|
||||
suffix: '%',
|
||||
mavg: { color: '#60a5fa', label: 'Report MA' }
|
||||
}
|
||||
];
|
||||
const trainingMetrics = [
|
||||
{
|
||||
key: 'startedRate',
|
||||
label: 'Started Rate',
|
||||
color: '#5b93e6',
|
||||
suffix: '%',
|
||||
mavg: { color: '#93c5fd', label: 'Started MA' }
|
||||
},
|
||||
{
|
||||
key: 'completionRate',
|
||||
label: 'Completion Rate',
|
||||
color: '#2fa968',
|
||||
suffix: '%',
|
||||
mavg: { color: '#69e1ab', label: 'Completed MA' }
|
||||
}
|
||||
];
|
||||
$: metrics = chartMode === 'training' ? trainingMetrics : phishingMetrics;
|
||||
|
||||
// toggle metric visibility (reassign to trigger svelte reactivity and persist)
|
||||
function toggleMetric(metricKey) {
|
||||
@@ -281,14 +341,12 @@
|
||||
const n = Math.min(trendN, chartData.length);
|
||||
if (n === 0) return null;
|
||||
const slice = chartData.slice(-n);
|
||||
const avg = (arr, key) => arr.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
|
||||
return {
|
||||
n,
|
||||
openRate: avg(slice, 'openRate'),
|
||||
clickRate: avg(slice, 'clickRate'),
|
||||
submissionRate: avg(slice, 'submissionRate'),
|
||||
reportRate: avg(slice, 'reportRate')
|
||||
};
|
||||
const avg = (key) => slice.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
|
||||
const out = { n };
|
||||
for (const m of metrics) {
|
||||
out[m.key] = avg(m.key);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
// --- Force chart rerender ---
|
||||
@@ -298,9 +356,12 @@
|
||||
.join('-');
|
||||
|
||||
// --- Data processing ---
|
||||
function processData(stats, useRelativeMetrics) {
|
||||
function processData(stats, useRelativeMetrics, mode) {
|
||||
// phishing and training campaigns are different sets with different funnels,
|
||||
// so the chart shows one at a time based on the selected mode
|
||||
const wantTraining = mode === 'training';
|
||||
const sortedStats = [...(stats || [])]
|
||||
.filter((stat) => stat.campaignClosedAt)
|
||||
.filter((stat) => stat.campaignClosedAt && !!stat.isTraining === wantTraining)
|
||||
.sort(
|
||||
(a, b) => new Date(a.campaignClosedAt).getTime() - new Date(b.campaignClosedAt).getTime()
|
||||
);
|
||||
@@ -310,25 +371,39 @@
|
||||
return d > 0 ? Math.round((n / d) * 1000) / 10 : 0;
|
||||
}
|
||||
|
||||
return sortedStats.map((stat, index) => ({
|
||||
index: index + 1,
|
||||
date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
|
||||
name: stat.campaignName || `Campaign ${index + 1}`,
|
||||
campaignId: stat.campaignId,
|
||||
openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
|
||||
clickRate: useRelativeMetrics
|
||||
? pct(stat.websiteVisits, stat.trackingPixelLoaded)
|
||||
: pct(stat.websiteVisits, stat.totalRecipients),
|
||||
submissionRate: useRelativeMetrics
|
||||
? pct(stat.dataSubmissions, stat.websiteVisits)
|
||||
: pct(stat.dataSubmissions, stat.totalRecipients),
|
||||
reportRate: pct(stat.reported, stat.totalRecipients),
|
||||
totalRecipients: stat.totalRecipients
|
||||
}));
|
||||
return sortedStats.map((stat, index) => {
|
||||
const base = {
|
||||
index: index + 1,
|
||||
date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
|
||||
name: stat.campaignName || `Campaign ${index + 1}`,
|
||||
campaignId: stat.campaignId,
|
||||
totalRecipients: stat.totalRecipients
|
||||
};
|
||||
if (wantTraining) {
|
||||
return {
|
||||
...base,
|
||||
startedRate: pct(stat.trainingStarted, stat.totalRecipients),
|
||||
completionRate: useRelativeMetrics
|
||||
? pct(stat.trainingCompleted, stat.trainingStarted)
|
||||
: pct(stat.trainingCompleted, stat.totalRecipients)
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
|
||||
clickRate: useRelativeMetrics
|
||||
? pct(stat.websiteVisits, stat.trackingPixelLoaded)
|
||||
: pct(stat.websiteVisits, stat.totalRecipients),
|
||||
submissionRate: useRelativeMetrics
|
||||
? pct(stat.dataSubmissions, stat.websiteVisits)
|
||||
: pct(stat.dataSubmissions, stat.totalRecipients),
|
||||
reportRate: pct(stat.reported, stat.totalRecipients)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Use filtered campaign stats based on selected time range and relative metrics toggle
|
||||
$: chartData = processData(filteredCampaignStats, useRelativeMetrics);
|
||||
// Use filtered campaign stats based on selected time range, relative metrics and mode
|
||||
$: chartData = processData(filteredCampaignStats, useRelativeMetrics, chartMode);
|
||||
|
||||
function createChart() {
|
||||
if (!chartContainer || chartData.length < 2) return;
|
||||
@@ -385,10 +460,9 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
// Only draw moving average for clickRate, submissionRate, and reportRate that are visible
|
||||
['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => {
|
||||
const metric = metrics.find((m) => m.key === metricKey);
|
||||
if (metric && visibleMetrics[`mavg-${metricKey}`]) {
|
||||
// draw the moving average for any metric that supports one and is toggled on
|
||||
metrics.forEach((metric) => {
|
||||
if (metric.mavg && visibleMetrics[`mavg-${metric.key}`]) {
|
||||
createMovingAverageLine(svg, metric, movingAvgN);
|
||||
}
|
||||
});
|
||||
@@ -456,15 +530,8 @@
|
||||
if (started) {
|
||||
path.setAttribute('d', pathData);
|
||||
path.setAttribute('fill', 'none');
|
||||
// Use a lighter shade of the metric color for moving average
|
||||
let avgColor = metric.color;
|
||||
if (metric.key === 'openRate') {
|
||||
avgColor = '#93c5fd'; // light blue
|
||||
} else if (metric.key === 'submissionRate') {
|
||||
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
|
||||
} else if (metric.key === 'reportRate') {
|
||||
avgColor = '#60a5fa'; // lighter blue for report rate
|
||||
}
|
||||
// lighter shade defined on the metric, falling back to the metric color
|
||||
const avgColor = (metric.mavg && metric.mavg.color) || metric.color;
|
||||
path.setAttribute('stroke', avgColor);
|
||||
path.setAttribute('stroke-width', '1.2');
|
||||
path.setAttribute('stroke-dasharray', '6,4');
|
||||
@@ -822,29 +889,12 @@
|
||||
strokeDasharray: null,
|
||||
opacity: 1
|
||||
});
|
||||
if (
|
||||
metric.key === 'clickRate' ||
|
||||
metric.key === 'submissionRate' ||
|
||||
metric.key === 'reportRate'
|
||||
) {
|
||||
// Use a lighter version of the main color for moving averages
|
||||
let avgColor = metric.color;
|
||||
let avgLabel = '';
|
||||
if (metric.key === 'clickRate') {
|
||||
avgColor = '#eea5fa'; // before-page-visited, lighter pink
|
||||
avgLabel = 'Click MA';
|
||||
} else if (metric.key === 'submissionRate') {
|
||||
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
|
||||
avgLabel = 'Submit MA';
|
||||
} else if (metric.key === 'reportRate') {
|
||||
avgColor = '#60a5fa'; // lighter blue for report rate
|
||||
avgLabel = 'Report MA';
|
||||
}
|
||||
if (metric.mavg) {
|
||||
legendItems.push({
|
||||
type: 'mavg',
|
||||
key: metric.key,
|
||||
label: avgLabel,
|
||||
color: avgColor,
|
||||
label: metric.mavg.label,
|
||||
color: metric.mavg.color,
|
||||
class: `legend-line legend-mavg legend-mavg-${metric.key}`,
|
||||
labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`,
|
||||
dataMetric: `mavg-${metric.key}`,
|
||||
@@ -1226,6 +1276,7 @@
|
||||
// reference toggles so Svelte tracks them for reactivity
|
||||
useLogScale;
|
||||
useRelativeMetrics;
|
||||
chartMode;
|
||||
createChart();
|
||||
}
|
||||
}
|
||||
@@ -1319,6 +1370,30 @@
|
||||
Trendline: Last {trendStats ? trendStats.n : campaignStats.length} Campaigns (average)
|
||||
</h4>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-0">
|
||||
{#if hasTraining}
|
||||
<div
|
||||
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'phishing'
|
||||
? 'bg-cta-blue text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
on:click={() => (chartMode = 'phishing')}
|
||||
>
|
||||
Phishing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'training'
|
||||
? 'bg-training-completed text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
on:click={() => (chartMode = 'training')}
|
||||
>
|
||||
Training
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<label
|
||||
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
@@ -1383,7 +1458,10 @@
|
||||
</div>
|
||||
<div class="mb-8"></div>
|
||||
{#if campaignStats.length > 0}
|
||||
<div class="grid grid-cols-4 gap-2 sm:gap-4">
|
||||
<div
|
||||
class="grid gap-2 sm:gap-4"
|
||||
style="grid-template-columns: repeat({metrics.length}, minmax(0, 1fr));"
|
||||
>
|
||||
{#each metrics as metric}
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
campaign_recipient_deny_page_visited: '#ff6b35',
|
||||
campaign_recipient_submitted_data: '#f42e41',
|
||||
campaign_recipient_reported: '#2c3e50',
|
||||
campaign_recipient_training_started: '#5b93e6',
|
||||
campaign_recipient_training_completed: '#2fa968',
|
||||
campaign_recipient_info: '#94cae6'
|
||||
});
|
||||
|
||||
@@ -62,6 +64,10 @@
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
campaign_recipient_reported:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
|
||||
campaign_recipient_training_started:
|
||||
'<svg fill="currentColor" viewBox="0 0 24 24"><path d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.949 49.949 0 0 0-9.902 3.912l-.003.002a48.9 48.9 0 0 0-.34.18.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.615 54.615 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.123 56.123 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.803 49.803 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.653 60.653 0 0 1 11.7 2.805Z"/></svg>',
|
||||
campaign_recipient_training_completed:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
campaign_recipient_info:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||||
});
|
||||
@@ -130,6 +136,8 @@
|
||||
campaign_recipient_deny_page_visited: true,
|
||||
campaign_recipient_submitted_data: true,
|
||||
campaign_recipient_reported: true,
|
||||
campaign_recipient_training_started: true,
|
||||
campaign_recipient_training_completed: true,
|
||||
campaign_recipient_info: true
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
|
||||
class="h-full bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
|
||||
>
|
||||
<div
|
||||
class="text-grayblue-dark dark:text-gray-500 text-sm font-semibold transition-colors duration-200"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<span
|
||||
title="Awareness training"
|
||||
class="select-none px-1 border-2 border-training-completed relative -top-1 rounded-lg text-xs text-training-completed bg-white dark:bg-gray-800 transition-colors duration-200"
|
||||
>
|
||||
training
|
||||
</span>
|
||||
@@ -41,6 +41,18 @@ export const eventNameMap = {
|
||||
priority: 95,
|
||||
color: 'bg-reported'
|
||||
},
|
||||
// the milestones outrank the raw page visits they are recorded on top of, as
|
||||
// they do in the backend priorities
|
||||
campaign_recipient_training_started: {
|
||||
name: 'Training Started',
|
||||
priority: 72,
|
||||
color: 'bg-training-started'
|
||||
},
|
||||
campaign_recipient_training_completed: {
|
||||
name: 'Training Completed',
|
||||
priority: 75,
|
||||
color: 'bg-training-completed'
|
||||
},
|
||||
campaign_recipient_info: {
|
||||
name: 'Info',
|
||||
priority: 25,
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
import TrainingLabel from '$lib/components/TrainingLabel.svelte';
|
||||
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
|
||||
import {
|
||||
createTableSelection,
|
||||
@@ -69,7 +71,8 @@
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
lureCodeLength: 12,
|
||||
isTraining: false
|
||||
};
|
||||
|
||||
// codes are matched exactly, so the choice is about which glyphs appear and
|
||||
@@ -412,6 +415,7 @@
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
|
||||
isTraining: formValues.isTraining,
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
@@ -454,7 +458,8 @@
|
||||
urlPath: formValues.urlPath || '',
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
|
||||
isTraining: formValues.isTraining
|
||||
});
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
@@ -516,7 +521,8 @@
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
lureCodeLength: 12,
|
||||
isTraining: false
|
||||
};
|
||||
modalError = '';
|
||||
showAdvancedOptions = false;
|
||||
@@ -632,6 +638,7 @@
|
||||
formValues.lureURLMode = template.lureURLMode || 'query';
|
||||
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
|
||||
formValues.lureCodeLength = template.lureCodeLength || 12;
|
||||
formValues.isTraining = !!template.isTraining;
|
||||
|
||||
// set advanced options visibility based on template configuration
|
||||
showAdvancedOptions = !!(
|
||||
@@ -712,7 +719,12 @@
|
||||
title={template.name}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{template.name}
|
||||
<span class="flex items-center gap-2">
|
||||
{#if template.isTraining}
|
||||
<TrainingLabel />
|
||||
{/if}
|
||||
{template.name}
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -980,6 +992,14 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="isTraining"
|
||||
inline
|
||||
bind:value={formValues.isTraining}
|
||||
toolTipText="Tracks training events instead of phishing events."
|
||||
>
|
||||
Awareness training
|
||||
</CheckboxField>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Configuration Section -->
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TestLabel from '$lib/components/TestLabel.svelte';
|
||||
import TrainingLabel from '$lib/components/TrainingLabel.svelte';
|
||||
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
|
||||
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
|
||||
import {
|
||||
@@ -169,6 +170,8 @@
|
||||
'campaign_recipient_after_page_visited',
|
||||
'campaign_recipient_evasion_page_visited',
|
||||
'campaign_recipient_deny_page_visited',
|
||||
'campaign_recipient_training_started',
|
||||
'campaign_recipient_training_completed',
|
||||
'campaign_closed'
|
||||
];
|
||||
|
||||
@@ -183,7 +186,9 @@
|
||||
campaign_recipient_before_page_visited: 'Before Page Visited',
|
||||
campaign_recipient_page_visited: 'Page Visited',
|
||||
campaign_recipient_after_page_visited: 'After Page Visited',
|
||||
campaign_recipient_deny_page_visited: 'Deny Page Visited'
|
||||
campaign_recipient_deny_page_visited: 'Deny Page Visited',
|
||||
campaign_recipient_training_started: 'Training Started',
|
||||
campaign_recipient_training_completed: 'Training Completed'
|
||||
};
|
||||
|
||||
// create display options array with nice names
|
||||
@@ -203,10 +208,12 @@
|
||||
campaign_recipient_before_page_visited: 1 << 6, // 64
|
||||
campaign_recipient_page_visited: 1 << 7, // 128
|
||||
campaign_recipient_after_page_visited: 1 << 8, // 256
|
||||
campaign_recipient_deny_page_visited: 1 << 9 // 512
|
||||
campaign_recipient_deny_page_visited: 1 << 9, // 512
|
||||
campaign_recipient_training_started: 1 << 10, // 1024
|
||||
campaign_recipient_training_completed: 1 << 11 // 2048
|
||||
};
|
||||
|
||||
const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected
|
||||
const WEBHOOK_EVENT_ALL_BITS = 4095; // 2^12 - 1, all 12 events selected
|
||||
|
||||
// convert array of event names to bitwise int
|
||||
// if all events are selected, return 0 to preserve the "all events" semantic
|
||||
@@ -1613,6 +1620,9 @@
|
||||
{#if campaign.isTest}
|
||||
<TestLabel />
|
||||
{/if}
|
||||
{#if campaign.isTraining}
|
||||
<TrainingLabel />
|
||||
{/if}
|
||||
{campaign.name}
|
||||
</TableCellLink>
|
||||
<TableCell>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
saveSubmittedData: false,
|
||||
saveBrowserMetadata: false,
|
||||
isAnonymous: false,
|
||||
isTraining: false,
|
||||
scheduleAt: null,
|
||||
|
||||
allowDenyIDs: [],
|
||||
@@ -113,7 +114,9 @@
|
||||
trackingPixelLoaded: 0,
|
||||
websiteLoaded: 0,
|
||||
submittedData: 0,
|
||||
reported: 0
|
||||
reported: 0,
|
||||
trainingStarted: 0,
|
||||
trainingCompleted: 0
|
||||
};
|
||||
// @ts-ignore
|
||||
const recipientTableUrlParams = newTableURLParams({
|
||||
@@ -320,6 +323,7 @@
|
||||
campaign.closeAt = t.closeAt;
|
||||
campaign.closedAt = t.closedAt;
|
||||
campaign.isTest = t.isTest;
|
||||
campaign.isTraining = t.isTraining;
|
||||
campaign.constraintWeekDays = t.constraintWeekDays;
|
||||
campaign.constraintStartTime = t.constraintStartTime;
|
||||
campaign.constraintEndTime = t.constraintEndTime;
|
||||
@@ -509,6 +513,8 @@
|
||||
result.websiteLoaded = res.data.clickedLink;
|
||||
result.submittedData = res.data.submittedData;
|
||||
result.reported = res.data.reported;
|
||||
result.trainingStarted = res.data.trainingStarted;
|
||||
result.trainingCompleted = res.data.trainingCompleted;
|
||||
} catch (e) {
|
||||
addToast('Failed to load campaign result stats', 'Error');
|
||||
console.error('failed to load campaign result stats', e);
|
||||
@@ -1640,29 +1646,70 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<StatsCard
|
||||
title="Website Visits"
|
||||
value={result.websiteLoaded}
|
||||
borderColor="border-page-visited"
|
||||
iconColor="text-page-visited"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.recipients) * 100),
|
||||
relativeTo: 'of recipients',
|
||||
baseValue: result.recipients
|
||||
},
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
|
||||
relativeTo: 'of sent',
|
||||
baseValue: result.emailsSent
|
||||
},
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
|
||||
relativeTo: 'of reads',
|
||||
baseValue: result.trackingPixelLoaded
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#if campaign.isTraining}
|
||||
<StatsCard
|
||||
title="Training Started"
|
||||
value={result.trainingStarted}
|
||||
borderColor="border-training-started"
|
||||
iconColor="text-training-started"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.trainingStarted / result.recipients) * 100),
|
||||
relativeTo: 'of recipients',
|
||||
baseValue: result.recipients
|
||||
},
|
||||
{
|
||||
value: Math.round((result.trainingStarted / result.emailsSent) * 100),
|
||||
relativeTo: 'of sent',
|
||||
baseValue: result.emailsSent
|
||||
},
|
||||
{
|
||||
value: Math.round((result.trainingStarted / result.trackingPixelLoaded) * 100),
|
||||
relativeTo: 'of reads',
|
||||
baseValue: result.trackingPixelLoaded
|
||||
}
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
{:else}
|
||||
<StatsCard
|
||||
title="Website Visits"
|
||||
value={result.websiteLoaded}
|
||||
borderColor="border-page-visited"
|
||||
iconColor="text-page-visited"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.recipients) * 100),
|
||||
relativeTo: 'of recipients',
|
||||
baseValue: result.recipients
|
||||
},
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
|
||||
relativeTo: 'of sent',
|
||||
baseValue: result.emailsSent
|
||||
},
|
||||
{
|
||||
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
|
||||
relativeTo: 'of reads',
|
||||
baseValue: result.trackingPixelLoaded
|
||||
}
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -1680,11 +1727,47 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<StatsCard
|
||||
title="Data Submitted"
|
||||
value={result.submittedData}
|
||||
borderColor="border-submitted-data"
|
||||
iconColor="text-submitted-data"
|
||||
{/if}
|
||||
|
||||
{#if campaign.isTraining}
|
||||
<StatsCard
|
||||
title="In progress"
|
||||
value={Math.max(result.trainingStarted - result.trainingCompleted, 0)}
|
||||
borderColor="border-training-started"
|
||||
iconColor="text-training-started"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round(
|
||||
((result.trainingStarted - result.trainingCompleted) / result.trainingStarted) *
|
||||
100
|
||||
),
|
||||
relativeTo: 'of started',
|
||||
baseValue: result.trainingStarted
|
||||
}
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
{:else}
|
||||
<StatsCard
|
||||
title="Data Submitted"
|
||||
value={result.submittedData}
|
||||
borderColor="border-submitted-data"
|
||||
iconColor="text-submitted-data"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.submittedData / result.recipients) * 100),
|
||||
@@ -1725,11 +1808,59 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<StatsCard
|
||||
title="Reported"
|
||||
value={result.reported}
|
||||
borderColor="border-reported"
|
||||
iconColor="text-reported"
|
||||
{/if}
|
||||
|
||||
{#if campaign.isTraining}
|
||||
<StatsCard
|
||||
title="Training Completed"
|
||||
value={result.trainingCompleted}
|
||||
borderColor="border-training-completed"
|
||||
iconColor="text-training-completed"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.trainingCompleted / result.recipients) * 100),
|
||||
relativeTo: 'of recipients',
|
||||
baseValue: result.recipients
|
||||
},
|
||||
{
|
||||
value: Math.round((result.trainingCompleted / result.emailsSent) * 100),
|
||||
relativeTo: 'of sent',
|
||||
baseValue: result.emailsSent
|
||||
},
|
||||
{
|
||||
value: Math.round((result.trainingCompleted / result.trackingPixelLoaded) * 100),
|
||||
relativeTo: 'of reads',
|
||||
baseValue: result.trackingPixelLoaded
|
||||
},
|
||||
{
|
||||
value: Math.round((result.trainingCompleted / result.trainingStarted) * 100),
|
||||
relativeTo: 'of started',
|
||||
baseValue: result.trainingStarted
|
||||
}
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
{:else}
|
||||
<StatsCard
|
||||
title="Reported"
|
||||
value={result.reported}
|
||||
borderColor="border-reported"
|
||||
iconColor="text-reported"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.reported / result.recipients) * 100),
|
||||
@@ -1769,6 +1900,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
{/if}
|
||||
</div>
|
||||
<div class=" mb-6">
|
||||
<SubHeadline>Event Timeline</SubHeadline>
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
|
||||
let calendarCampaigns = [];
|
||||
let campaignStats = [];
|
||||
|
||||
// training completion rate across closed training campaigns, derived from the
|
||||
// campaign stats snapshots already loaded for the trend chart
|
||||
$: trainingStats = (campaignStats || []).filter((s) => s.isTraining);
|
||||
$: trainingStarted = trainingStats.reduce((sum, s) => sum + (s.trainingStarted || 0), 0);
|
||||
$: trainingCompleted = trainingStats.reduce((sum, s) => sum + (s.trainingCompleted || 0), 0);
|
||||
$: trainingCompletionRate =
|
||||
trainingStarted > 0 ? Math.round((trainingCompleted / trainingStarted) * 100) : 0;
|
||||
let isCampaignStatsLoading = true; // start as true to show ghost on initial load
|
||||
|
||||
let calendarStartDate = null;
|
||||
@@ -252,8 +260,8 @@
|
||||
<SubHeadline>{contextCompanyName}</SubHeadline>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-4">
|
||||
<a href="/dashboard/campaigns">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8 mt-4">
|
||||
<a href="/dashboard/campaigns" class="block h-full">
|
||||
<StatsCard
|
||||
title="Active campaigns"
|
||||
value={active}
|
||||
@@ -278,7 +286,7 @@
|
||||
</StatsCard>
|
||||
</a>
|
||||
|
||||
<a href="/dashboard/campaigns">
|
||||
<a href="/dashboard/campaigns" class="block h-full">
|
||||
<StatsCard
|
||||
title="Upcoming campaigns"
|
||||
value={scheduled}
|
||||
@@ -303,7 +311,7 @@
|
||||
</StatsCard>
|
||||
</a>
|
||||
|
||||
<a href="/dashboard/campaigns">
|
||||
<a href="/dashboard/campaigns" class="block h-full">
|
||||
<StatsCard
|
||||
title="Completed campaigns"
|
||||
value={finished}
|
||||
@@ -328,7 +336,7 @@
|
||||
</StatsCard>
|
||||
</a>
|
||||
|
||||
<a href="/recipient">
|
||||
<a href="/recipient" class="block h-full">
|
||||
<StatsCard
|
||||
title="Repeat offenders"
|
||||
value={repeatOffenders}
|
||||
@@ -352,6 +360,31 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
</a>
|
||||
|
||||
<a href="/campaign" class="block h-full">
|
||||
<StatsCard
|
||||
title="Trainings completed"
|
||||
value={trainingCompleted}
|
||||
borderColor="border-training-completed"
|
||||
iconColor="text-training-completed"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SubHeadline>{contextCompanyName ? 'Campaign Trends' : 'Shared Campaign Trends'}</SubHeadline>
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
campaignsTrackingPixelLoaded: 0,
|
||||
campaignsPhishingPageLoaded: 0,
|
||||
campaignsDataSubmitted: 0,
|
||||
campaignsReported: 0
|
||||
campaignsReported: 0,
|
||||
trainingsAssigned: 0,
|
||||
trainingsCompleted: 0
|
||||
};
|
||||
let isGroupsLoading = false;
|
||||
let isEventsLoading = false;
|
||||
@@ -158,7 +160,7 @@
|
||||
{/if}
|
||||
<BigButton on:click={onClickExport}>Export events</BigButton>
|
||||
<div>
|
||||
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-7 gap-6 mb-8 mt-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-7 gap-6 mb-8 mt-4">
|
||||
<!-- Campaigns card -->
|
||||
<StatsCard
|
||||
title="Campaigns"
|
||||
@@ -333,6 +335,14 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
</div>
|
||||
|
||||
{#if stats.trainingsAssigned > 0}
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mb-8 text-sm">
|
||||
<span class="font-semibold text-grayblue-dark dark:text-gray-400">Training</span>
|
||||
<span class="text-grayblue-dark dark:text-gray-400">Assigned <span class="font-semibold text-pc-darkblue dark:text-gray-200">{stats.trainingsAssigned}</span></span>
|
||||
<span class="text-grayblue-dark dark:text-gray-400">Completed <span class="font-semibold text-training-completed">{stats.trainingsCompleted}</span></span>
|
||||
</div>
|
||||
{/if}
|
||||
<SubHeadline>Groups</SubHeadline>
|
||||
<Table
|
||||
columns={['Name']}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
let reportTemplateID = null;
|
||||
let reportTemplateError = '';
|
||||
let isReportTemplateSubmitting = false;
|
||||
// the phishing and training reports are separate templates; edit one at a time
|
||||
let reportRows = [];
|
||||
let reportKind = 'phishing'; // 'phishing' | 'training'
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -90,15 +93,11 @@
|
||||
const openReportTemplateModal = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
reportTemplateContent = '';
|
||||
reportTemplateID = null;
|
||||
reportTemplateError = '';
|
||||
reportKind = 'phishing';
|
||||
const response = await api.reportTemplate.getAll(null);
|
||||
if (response.success && response.data?.rows?.length > 0) {
|
||||
const tmpl = response.data.rows[0];
|
||||
reportTemplateContent = tmpl.content || '';
|
||||
reportTemplateID = tmpl.id || null;
|
||||
}
|
||||
reportRows = response.success ? response.data?.rows || [] : [];
|
||||
selectReportKind('phishing');
|
||||
} catch (error) {
|
||||
console.error('Failed to load report template:', error);
|
||||
reportTemplateError = 'Failed to load template';
|
||||
@@ -108,6 +107,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
// loads the cached template row for the selected kind into the editor
|
||||
const selectReportKind = (kind) => {
|
||||
reportKind = kind;
|
||||
reportTemplateError = '';
|
||||
const row = reportRows.find((r) => !!r.isTraining === (kind === 'training'));
|
||||
reportTemplateContent = row?.content || '';
|
||||
reportTemplateID = row?.id || null;
|
||||
};
|
||||
|
||||
// re-fetch the templates after a save so the active kind keeps its id
|
||||
const refreshReportRows = async () => {
|
||||
const response = await api.reportTemplate.getAll(null);
|
||||
reportRows = response.success ? response.data?.rows || [] : [];
|
||||
const row = reportRows.find((r) => !!r.isTraining === (reportKind === 'training'));
|
||||
if (row?.id) {
|
||||
reportTemplateID = row.id;
|
||||
}
|
||||
};
|
||||
|
||||
const closeReportTemplateModal = () => {
|
||||
isReportTemplateModalVisible = false;
|
||||
reportTemplateError = '';
|
||||
@@ -124,12 +142,16 @@
|
||||
content: reportTemplateContent
|
||||
});
|
||||
} else {
|
||||
response = await api.reportTemplate.create({ content: reportTemplateContent });
|
||||
response = await api.reportTemplate.create({
|
||||
content: reportTemplateContent,
|
||||
isTraining: reportKind === 'training'
|
||||
});
|
||||
if (response.success && response.data?.id) {
|
||||
reportTemplateID = response.data.id;
|
||||
}
|
||||
}
|
||||
if (response.success) {
|
||||
await refreshReportRows();
|
||||
addToast('Report template saved', 'Success');
|
||||
if (!saveOnly) {
|
||||
isReportTemplateModalVisible = false;
|
||||
@@ -219,6 +241,31 @@
|
||||
<div
|
||||
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-sm font-semibold text-gray-600 dark:text-gray-300">Report for</span>
|
||||
<div
|
||||
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 transition-colors duration-200 {reportKind === 'phishing'
|
||||
? 'bg-cta-blue text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
on:click={() => selectReportKind('phishing')}
|
||||
>
|
||||
Phishing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 transition-colors duration-200 {reportKind === 'training'
|
||||
? 'bg-training-completed text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
on:click={() => selectReportKind('training')}
|
||||
>
|
||||
Training
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Editor contentType="report" bind:value={reportTemplateContent} />
|
||||
<FormError message={reportTemplateError} />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user