added scripts feature

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-19 11:31:42 +02:00
1 parent a32d2ef4fb
commit f7f73dd531
37 files changed
+4319 -82

No files matched your search

+94
View File
@@ -595,6 +595,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -623,6 +624,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -683,6 +685,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -710,6 +713,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -3355,6 +3359,96 @@ export class API {
}
};
/**
* script is the API for script related operations.
* Scripts attach to a campaign like webhooks but run a script.
*/
script = {
/**
* Create a new script.
*
* @param {Object} script
* @param {string} script.name
* @param {string} script.script
* @param {string} [script.companyID]
* @returns {Promise<ApiResponse>}
*/
create: async ({ name, script, companyID }) => {
return await postJSON(this.getPath('/script'), {
name: name,
script: script,
// send null (global scope) rather than an empty string, which the
// backend would try to parse as a UUID
companyID: companyID || null
});
},
/**
* GetAll scripts.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAll: async (options, companyID = null) => {
return await getJSON(
this.getPath(`/script?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Get a script by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getByID: async (id) => {
return await getJSON(this.getPath(`/script/${id}`));
},
/**
* Update a script.
*
* @param {Object} script
* @param {string} script.id
* @param {string} script.name
* @param {string} script.script
* @param {string} [script.companyID]
* @returns {Promise<ApiResponse>}
*/
update: async ({ id, name, script, companyID }) => {
return await patchJSON(this.getPath(`/script/${id}`), {
name: name,
script: script,
companyID: companyID || null
});
},
/**
* Delete a script by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/script/${id}`));
},
/**
* Test-run a script against a simulated campaign event. Captures what the
* script does (logs, info/emitEvent, fetches, errors) without touching a
* campaign.
*
* @param {Object} args
* @param {string} args.script
* @param {Object} args.event
* @returns {Promise<ApiResponse>}
*/
test: async ({ script, event }) => {
return await postJSON(this.getPath('/script/test'), { script, event });
}
};
/**
* identifier is for campaign identifiers, ala. 'rid' in gophish
*/
@@ -310,6 +310,7 @@
class="flex items-center relative"
class:w-28={size == 'small'}
class:w-60={size == 'normal'}
class:w-full={size == 'full'}
>
<input
bind:this={inputElement}
@@ -166,6 +166,11 @@
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`,
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
`
};
@@ -176,6 +181,7 @@
'/campaign-template/': 'campaign_templates',
'/filter/': 'filters',
'/webhook/': 'webhooks',
'/script/': 'script',
'/recipient/': 'recipients_overview',
'/recipient/group/': 'recipient_groups',
'/domain/': 'domains_overview',
@@ -107,6 +107,11 @@
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`,
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
`,
tools: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
@@ -129,6 +134,7 @@
'/campaign-template/': 'campaign_templates',
'/filter/': 'filters',
'/webhook/': 'webhooks',
'/script/': 'script',
'/recipient/': 'recipients_overview',
'/recipient/group/': 'recipient_groups',
'/domain/': 'domains_overview',
@@ -0,0 +1,595 @@
<script>
import { onMount } from 'svelte';
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import { vimModeEnabled } from '$lib/store/vimMode.js';
import {
setupVimClipboardIntegration,
destroyVimClipboardIntegration
} from '$lib/utils/vimClipboard.js';
import * as vimModule from 'monaco-vim';
import TextField from '$lib/components/TextField.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
import { api } from '$lib/api/apiProxy.js';
import { eventDisplayLabel } from '$lib/consts/events.js';
// --- test runner state ---------------------------------------------------
const triggerEvents = [
'campaign_recipient_message_sent',
'campaign_recipient_message_read',
'campaign_recipient_before_page_visited',
'campaign_recipient_page_visited',
'campaign_recipient_after_page_visited',
'campaign_recipient_submitted_data',
'campaign_recipient_reported',
'campaign_recipient_evasion_page_visited',
'campaign_recipient_deny_page_visited',
'campaign_recipient_training_started',
'campaign_recipient_training_completed'
];
const triggerEventOptions = triggerEvents.map((e) => ({ value: e, label: eventDisplayLabel(e) }));
// only submitted_data carries a data payload (the parsed form body, same as the
// webhook data); every other event has no data attached
const dataBearingEvents = new Set(['campaign_recipient_submitted_data']);
let testEventName = 'campaign_recipient_submitted_data';
let testCampaignName = 'Test Campaign';
let testEmail = 'target@example.test';
let testDataText = '{\n "username": "victim",\n "password": "hunter2"\n}';
let testResult = null;
let testError = '';
let testing = false;
// 'script' shows the editor full-size, 'test' shows the test runner full-size
let view = 'script';
$: eventHasData = dataBearingEvents.has(testEventName);
async function runTest() {
testError = '';
testResult = null;
let data = {};
if (eventHasData && testDataText.trim()) {
try {
data = JSON.parse(testDataText);
} catch (e) {
testError = 'Event data is not valid JSON: ' + e.message;
return;
}
}
testing = true;
try {
const res = await api.script.test({
script,
event: { name: testEventName, campaignName: testCampaignName, email: testEmail, data }
});
if (!res.success) {
testError = res.error || 'Test failed';
return;
}
testResult = res.data;
} catch (e) {
testError = e?.message || String(e);
} finally {
testing = false;
}
}
// Props
/** @type {string} */
export let name = '';
/** @type {string} script is the JS source to edit */
export let script = '';
let editor;
let editorContainer;
let completionProvider;
let isDark = false;
let vimStatusBarEl = null;
let vimModeInstance = null;
let localVimMode = false;
let isDestroyed = false;
// scriptDTS is the single source of truth the editor consumes for
// autocomplete and inline type checking. It must match the bindings the
// backend script engine exposes (backend/script/script.go).
const scriptDTS = `
/** The event that triggered this script. Already filtered for the
* campaign's anonymity and the configured data level, so fields may be empty. */
declare const event: {
/** event name, e.g. "campaign_recipient_submitted_data" */
name: string;
/** the campaign id */
campaignId: string;
/** the recipient id, empty for campaign level events */
recipientId: string;
/** the campaign name, empty at the "none" data level */
campaignName: string;
/** the recipient email, only at "full" level on a non anonymous campaign */
email: string;
/** captured data, only at "full" level */
data: Record<string, any>;
};
interface FetchOptions {
/** HTTP method: GET (default), POST, PUT, PATCH, DELETE, ... */
method?: string;
/** request headers, e.g. { 'Content-Type': 'application/json' } */
headers?: Record<string, string>;
/** request body (a string; use encode.json(obj) for JSON) */
body?: string;
/** route this request through a proxy: http://, https:// or socks5:// */
proxy?: string;
/** per request timeout in milliseconds (max 30000) */
timeoutMs?: number;
}
interface FetchResponse {
/** HTTP status code, e.g. 200 */
status: number;
/** response headers */
headers: Record<string, string>;
/** response body as a string (use decode.json(res.body) to parse JSON) */
body: string;
}
declare const http: {
/**
* Send a synchronous HTTP request and return the response. Any method is
* supported. Blocks until the response arrives or the timeout fires; throws
* on a network error, so wrap it in try/catch if you want to handle failures.
*
* @example
* // GET and parse JSON
* const res = http.fetch('https://api.example.test/users/42');
* if (res.status === 200) {
* const user = decode.json(res.body);
* log('got user', { name: user.name });
* }
*
* @example
* // capture from a response body with a regex — the whole JS RegExp API
* // works (match/matchAll/exec/replace/test); use numbered groups (m[1])
* const res = http.fetch('https://api.example.test/login');
* const m = res.body.match(/"csrf_token":"([A-Za-z0-9._-]+)"/);
* const token = m && m[1];
*
* @example
* // POST JSON with headers
* const res = http.fetch('https://api.example.test/hook', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: encode.json({ event: event.name, email: event.email })
* });
*
* @example
* // PUT with a signed header and a timeout
* http.fetch('https://api.example.test/items/1', {
* method: 'PUT',
* headers: { 'X-Signature': hmac.sha256(secret, body) },
* body: body,
* timeoutMs: 5000
* });
*/
fetch(url: string, options?: FetchOptions): FetchResponse;
};
/** Output encoding for hash / hmac / random. Defaults to "hex". */
type OutputEncoding = 'hex' | 'base64' | 'base64url' | 'base32';
declare const encode: {
base64(s: string): string;
/** url-safe base64, no padding (JWT / OAuth) */
base64url(s: string): string;
base32(s: string): string;
hex(s: string): string;
/** query escape (space as +) */
url(s: string): string;
/** path segment escape */
urlPath(s: string): string;
html(s: string): string;
/** JSON stringify any value */
json(value: any): string;
/** object -> application/x-www-form-urlencoded */
form(obj: Record<string, any>): string;
/** gzip then base64 */
gzip(s: string): string;
/** raw deflate then base64 */
deflate(s: string): string;
};
declare const decode: {
base64(s: string): string;
base64url(s: string): string;
base32(s: string): string;
hex(s: string): string;
url(s: string): string;
urlPath(s: string): string;
html(s: string): string;
/** JSON parse a string into a value */
json(s: string): any;
/** form-encoded string -> object (repeated key -> array) */
form(s: string): Record<string, any>;
/** base64 of gzip -> string */
gzip(s: string): string;
/** base64 of deflate -> string */
deflate(s: string): string;
};
/** Hashing. hash.sha256(input) -> hex; pass an encoding for base64 etc. */
declare const hash: {
md5(input: string, encoding?: OutputEncoding): string;
sha1(input: string, encoding?: OutputEncoding): string;
sha256(input: string, encoding?: OutputEncoding): string;
sha384(input: string, encoding?: OutputEncoding): string;
sha512(input: string, encoding?: OutputEncoding): string;
};
/** Keyed HMAC signing, e.g. for request or webhook signatures. */
declare const hmac: {
sha1(key: string, message: string, encoding?: OutputEncoding): string;
sha256(key: string, message: string, encoding?: OutputEncoding): string;
sha384(key: string, message: string, encoding?: OutputEncoding): string;
sha512(key: string, message: string, encoding?: OutputEncoding): string;
};
declare const jwt: {
/** Split and JSON-parse a JWT. Does NOT verify the signature. */
decode(token: string): { header: any; payload: any; signature: string };
};
/** Secure random for nonces, PKCE verifiers, OAuth state. */
declare const random: {
/** n random bytes (1..4096) in the given encoding, default hex */
bytes(n: number, encoding?: OutputEncoding): string;
uuid(): string;
};
/** Write a line to the SERVER logs (for debugging). Not visible in the app. */
declare function log(message: string, data?: any): void;
/** Record a campaign_recipient_info event, visible in the campaign timeline.
* The detail follows the campaign's data-retention and anonymity rules.
* Uncaught exceptions and timeouts are recorded automatically the same way. */
declare function info(message: string, data?: Record<string, any>): void;
/** The events a script may create with emitEvent. Server-detected outcomes
* (opens, clicks, reports, delivery, training) cannot be forged. */
type EmittableEvent = 'campaign_recipient_submitted_data' | 'campaign_recipient_info';
/** Create a new campaign event in this same campaign and recipient context, for
* data the script itself authored (e.g. a token obtained via http.fetch). Goes
* through the same storage and anonymization rules as native capture: data is
* stored only when the campaign keeps submitted data, and stripped on anonymous
* campaigns.
*
* @example
* // record a token the script redeemed as submitted data
* emitEvent('campaign_recipient_submitted_data', { accessToken: token }); */
declare function emitEvent(name: EmittableEvent, data?: Record<string, any>): void;
/** End the script early. */
declare function stop(): void;
`;
const defaultScript = `// Runs when a subscribed campaign event fires.
// Available: event, http.fetch, encode, decode, hash, hmac, jwt, random, log, info, emitEvent, stop
// (encode/decode/hash/hmac/jwt/random are the data transform toolkit)
log('event received', { name: event.name });
// event.data holds the submitted form fields on a submitted_data event.
// Read whatever fields your landing page posts:
var username = event.data && event.data.username;
if (username) {
info('captured a submission', { username: username });
}
// Example: forward the submitted data to an external service (set a real URL)
// const res = http.fetch('https://your-endpoint.test/hook', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: encode.json({ event: event.name, email: event.email, data: event.data })
// });
// const parsed = decode.json(res.body);
// log('forwarded', { status: res.status, id: parsed.id });
`;
function destroyVimMode() {
if (vimModeInstance) {
destroyVimClipboardIntegration();
vimModeInstance.dispose();
vimModeInstance = null;
}
}
onMount(() => {
if (!script) {
script = defaultScript;
}
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
const newIsDark = document.documentElement.classList.contains('dark');
if (newIsDark !== isDark) {
isDark = newIsDark;
if (editor) monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
/* @ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
}
};
// noLib removes the browser DOM lib so dot completion only shows the
// declared script API. 1108 is 'return' outside function, valid here
// because the script runs inside an implicit IIFE on the backend.
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
diagnosticCodesToIgnore: [1108]
});
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
noLib: true,
allowJs: true,
checkJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ES2020,
strict: false
});
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
scriptDTS,
'ts:script.d.ts'
);
editor = monaco.editor.create(editorContainer, {
value: script,
language: 'javascript',
theme: isDark ? 'vs-dark' : 'vs-light',
minimap: { enabled: false },
wordWrap: 'off',
folding: false,
scrollBeyondLastLine: false,
fontSize: 13,
automaticLayout: true
});
editor.onDidChangeModelContent(() => {
script = editor.getValue();
});
const unsubVim = vimModeEnabled.subscribe((enabled) => {
if (isDestroyed) return;
localVimMode = enabled;
if (enabled) {
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
} else {
destroyVimMode();
}
});
return () => {
isDestroyed = true;
observer.disconnect();
unsubVim();
destroyVimMode();
if (completionProvider) completionProvider.dispose();
if (editor) editor.dispose();
};
});
</script>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- top bar: name + view toggle -->
<div class="flex items-end gap-3">
<div class="min-w-0 flex-1">
<TextField bind:value={name} placeholder="my-script">Name</TextField>
</div>
<div
class="mb-2 inline-flex overflow-hidden rounded-md border border-gray-300 text-sm dark:border-gray-600"
>
<button
type="button"
on:click={() => (view = 'script')}
class="px-3 py-1.5 font-medium transition-colors {view === 'script'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
Script
</button>
<button
type="button"
on:click={() => (view = 'test')}
class="px-3 py-1.5 font-medium transition-colors {view === 'test'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
Test run
</button>
</div>
</div>
<!-- body: editor stays mounted; the test runner overlays it full-size -->
<div class="relative min-h-0 flex-1">
<!-- editor (kept sized with invisible, not display:none, so Monaco stays laid out) -->
<div
class="flex h-full flex-col overflow-hidden rounded border border-gray-300 dark:border-gray-700"
class:invisible={view !== 'script'}
>
<div
class="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">JavaScript</span>
<button
type="button"
title="Toggle vim mode"
on:click={() => vimModeEnabled.update((v) => !v)}
class="h-8 border-2 rounded-md w-20 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
class:font-bold={localVimMode}
class:bg-blue-600={localVimMode}
class:dark:bg-blue-500={localVimMode}
class:text-white={localVimMode}
class:border-blue-600={localVimMode}
class:dark:border-blue-500={localVimMode}
class:text-gray-700={!localVimMode}
class:dark:text-gray-200={!localVimMode}
class:bg-white={!localVimMode}
class:dark:bg-gray-700={!localVimMode}
class:border-gray-300={!localVimMode}
class:dark:border-gray-600={!localVimMode}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
</svg>
<span class="text-xs">Vim</span>
</button>
</div>
<div bind:this={editorContainer} class="flex-1" style="min-height: 0;"></div>
<div
bind:this={vimStatusBarEl}
class="h-5 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 px-2"
></div>
</div>
{#if view === 'test'}
<!-- fills the body; below lg it stacks and scrolls -->
<div
class="absolute inset-0 flex min-h-0 flex-col gap-4 overflow-y-auto lg:flex-row lg:overflow-hidden"
>
<!-- LEFT: input controls (scroll) with the Run button pinned below -->
<div class="flex min-h-0 flex-none flex-col lg:w-80 xl:w-96">
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pr-1">
<TextFieldSelect
id="script-test-event"
bind:value={testEventName}
options={triggerEventOptions}
size="normal"
>
Event
</TextFieldSelect>
<TextField bind:value={testCampaignName}>Campaign name</TextField>
<TextField bind:value={testEmail}>Recipient email</TextField>
<!-- only campaign_recipient_submitted_data carries data (the parsed form body);
other events have no data attached -->
{#if eventHasData}
<div class="flex w-full flex-col py-2">
<p class="py-1 font-semibold text-slate-600 dark:text-gray-400">Event data (JSON)</p>
<SimpleCodeEditor
bind:value={testDataText}
language="json"
height="medium"
showVimToggle={false}
showExpandButton={false}
/>
</div>
{/if}
{#if testError}
<div
class="rounded border border-red-300 bg-red-50 p-2 text-xs text-red-700 dark:border-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{testError}
</div>
{/if}
</div>
<button
type="button"
on:click={runTest}
disabled={testing}
class="mt-3 flex w-full flex-none items-center justify-center gap-2 rounded-md bg-gradient-to-b from-blue-500 to-indigo-400 px-4 py-2 text-sm font-semibold text-white transition-all duration-200 hover:from-blue-400 hover:to-indigo-400 disabled:cursor-not-allowed disabled:opacity-60 dark:from-blue-600 dark:to-indigo-500"
>
{#if testing}
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-white"></span>
Running…
{:else}
Run test
{/if}
</button>
</div>
<!-- RIGHT: run log -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Run log</span
>
{#if testing}
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-green-400"></span>
{/if}
</div>
{#if testResult || testError}
<button
type="button"
on:click={() => {
testResult = null;
testError = '';
}}
class="text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
{/if}
</div>
<!-- log body (same rendering as the remote browser editor) -->
<div
class="min-h-[16rem] flex-1 select-text space-y-0.5 overflow-y-auto rounded bg-gray-900 p-2 font-mono text-xs text-gray-200 lg:min-h-0 dark:bg-gray-950"
>
{#if !testResult}
<span class="text-gray-500">No events yet. Click Run test to execute the script.</span>
{:else}
{#each testResult.entries ?? [] as entry}
<div
class="leading-5 {entry.type === 'event'
? 'text-blue-300'
: entry.type === 'info'
? 'text-sky-300'
: entry.type === 'error'
? 'text-red-400'
: entry.type === 'done'
? 'text-green-400'
: 'text-gray-400'}"
>
{#if entry.type === 'event'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-blue-400"> emit </span>
<span class="text-yellow-400">{entry.key}</span>
<span class="text-gray-300"> = </span>
<span>{JSON.stringify(entry.value)}</span>
{:else if entry.type === 'info'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-sky-400"> ℹ info</span>
<span class="ml-1 text-sky-200">{entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{:else if entry.type === 'done'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-green-400"> ✓ done</span>
{:else}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span> {entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
// Human-readable display names for campaign events, shared so the campaign
// wizard and the script editor label events the same way instead of showing
// the raw internal names.
export const eventDisplayNames = {
campaign_closed: 'Campaign Closed',
campaign_recipient_message_sent: 'Message Sent',
campaign_recipient_message_failed: 'Message Failed',
campaign_recipient_message_read: 'Message Read',
campaign_recipient_submitted_data: 'Submitted Data',
campaign_recipient_reported: 'Reported',
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
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_training_started: 'Training Started',
campaign_recipient_training_completed: 'Training Completed'
};
// eventDisplayLabel returns the friendly label for an event name, falling back
// to the raw name for anything unmapped.
export const eventDisplayLabel = (name) => eventDisplayNames[name] || name;
+12 -1
View File
@@ -101,6 +101,11 @@ export const route = {
label: 'Webhooks',
route: '/webhook/'
},
script: {
label: 'Scripts',
route: '/script/',
blackbox: true
},
userGuide: {
label: 'User Guide',
route: 'https://phishing.club/guide/introduction/',
@@ -118,7 +123,13 @@ export const menu = [
{
label: 'Campaigns',
type: 'submenu',
items: [route.campaigns, route.campaignTemplates, route.allowDeny, route.webhook]
items: [
route.campaigns,
route.campaignTemplates,
route.allowDeny,
route.webhook,
route.script
]
},
{
+210 -17
View File
@@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { eventDisplayNames } from '$lib/consts/events.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -178,21 +179,8 @@
'campaign_closed'
];
// human-readable display names for webhook events
const webhookEventDisplayNames = {
campaign_closed: 'Campaign Closed',
campaign_recipient_message_sent: 'Message Sent',
campaign_recipient_message_failed: 'Message Failed',
campaign_recipient_message_read: 'Message Read',
campaign_recipient_submitted_data: 'Submitted Data',
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
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_training_started: 'Training Started',
campaign_recipient_training_completed: 'Training Completed'
};
// human-readable display names for webhook events (shared with the script editor)
const webhookEventDisplayNames = eventDisplayNames;
// create display options array with nice names
const webhookEventDisplayOptions = webhookEventOptions.map((event) => ({
@@ -279,6 +267,11 @@
let denyPageMap = new BiMap({});
let allowDenyMap = new BiMap({});
let webhookMap = new BiMap({});
// scripts reuse the webhook event bit map and data levels. The feature is
// only shown when the server has it enabled (the list endpoint returns 404
// otherwise).
let scriptMap = new BiMap({});
let scriptsEnabled = false;
let modalMode = null;
let scheduleType = 'basic';
let allowDenyType = 'none';
@@ -493,6 +486,7 @@
obfuscate: false,
selectedCount: 0,
webhooks: [], // array of {id, includeData, events}
scripts: [], // array of {id, includeData, events}
jitterMin: 0,
jitterMax: 0
};
@@ -768,6 +762,21 @@
return api.webhook.getAll(options, contextCompanyID);
});
webhookMap = BiMap.FromArrayOfObjects(webhooks);
// load scripts only when the feature is enabled; the endpoint returns
// 404 when disabled, in which case the scripts UI stays hidden.
try {
const res = await api.script.getAll({}, contextCompanyID);
if (res.success) {
scriptsEnabled = true;
const scripts = await fetchAllRows((options) => {
return api.script.getAll(options, contextCompanyID);
});
scriptMap = BiMap.FromArrayOfObjects(scripts);
}
} catch (e) {
scriptsEnabled = false;
}
};
// Parse a YYYY-MM-DD string as local midnight, not UTC midnight.
@@ -924,12 +933,19 @@
constraintStartTime: contraintStartTimeUTC,
constraintEndTime: contraintEndTimeUTC,
webhooks: formValues.webhooks
.filter((wh) => wh.id !== null)
.filter((wh) => wh.id)
.map((wh) => ({
webhookID: wh.id,
webhookIncludeData: wh.includeData,
webhookEvents: webhookEventsToBinary(wh.events)
})),
scripts: formValues.scripts
.filter((a) => a.id)
.map((a) => ({
scriptID: a.id,
scriptIncludeData: a.includeData,
scriptEvents: webhookEventsToBinary(a.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
@@ -1003,12 +1019,19 @@
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
webhooks: formValues.webhooks
.filter((wh) => wh.id !== null)
.filter((wh) => wh.id)
.map((wh) => ({
webhookID: wh.id,
webhookIncludeData: wh.includeData,
webhookEvents: webhookEventsToBinary(wh.events)
})),
scripts: formValues.scripts
.filter((a) => a.id)
.map((a) => ({
scriptID: a.id,
scriptIncludeData: a.includeData,
scriptEvents: webhookEventsToBinary(a.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
@@ -1160,6 +1183,7 @@
obfuscate: false,
selectedCount: 0,
webhooks: [],
scripts: [],
jitterMin: 0,
jitterMax: 0
};
@@ -1297,6 +1321,16 @@
}
// no webhooks
return [];
})(),
scripts: (() => {
if (campaign.scripts && campaign.scripts.length > 0) {
return campaign.scripts.map((a) => ({
id: a.scriptID,
includeData: a.scriptIncludeData ?? 'full',
events: webhookEventsFromBinary(a.scriptEvents ?? 0)
}));
}
return [];
})()
};
@@ -1345,6 +1379,7 @@
showAdvancedOptionsStep4 = !!(
campaign.webhookID ||
campaign.webhooks?.length ||
campaign.scripts?.length ||
campaign.denyPage ||
campaign.evasionPage ||
campaign.allowDeny?.length ||
@@ -1504,6 +1539,37 @@
formValues.webhooks = [...formValues.webhooks]; // trigger reactivity
};
// script helper functions, mirroring the webhook ones
const addScript = () => {
formValues.scripts = [
...formValues.scripts,
{
id: null,
includeData: 'full',
events: [...webhookEventOptions] // all events by default
}
];
};
const removeScript = (index) => {
formValues.scripts = formValues.scripts.filter((_, i) => i !== index);
};
const toggleScriptEvent = (scriptIndex, eventValue) => {
const script = formValues.scripts[scriptIndex];
const isSelected = script.events.includes(eventValue);
if (isSelected) {
// prevent unselecting the last item
if (script.events.length > 1) {
script.events = script.events.filter((e) => e !== eventValue);
}
} else {
script.events = [...script.events, eventValue];
}
formValues.scripts = [...formValues.scripts]; // trigger reactivity
};
// check if user is in the correct context for campaign actions
const isContextMismatch = (campaign) => {
const context = appStateService.getContext();
@@ -2368,6 +2434,110 @@
</div>
</div>
<ConditionalDisplay show="blackbox">
{#if scriptsEnabled}
<div class="mb-6 pt-4">
<div class="flex flex-col">
<div class="flex items-center py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400">Scripts</p>
<ToolTip>
Run a script when a campaign event fires. Each script has its own
data level and event filters, the same as webhooks.
</ToolTip>
<div
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
>
<p
class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200"
>
optional
</p>
</div>
</div>
<div class="space-y-3 max-w-lg">
{#each formValues.scripts as script, index}
<div
class="flex flex-col gap-3 p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800/50 dark:to-gray-800/30 rounded-lg border border-gray-300 dark:border-gray-600/50 shadow-sm hover:shadow-md transition-all duration-200"
>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextFieldSelect
id="script-{index}"
bind:value={script.id}
optional
options={scriptMap
.keys()
.map((k) => ({ value: k, label: scriptMap.byKey(k) }))}
>
Script
</TextFieldSelect>
</div>
<div class="flex items-end pb-4">
<button
type="button"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700/80 rounded-md transition-colors duration-200"
on:click={() => removeScript(index)}
title="Remove this script"
aria-label="Remove script"
>
<img class="w-4 flex-shrink-0" src="/delete2.svg" alt="" />
</button>
</div>
</div>
<div>
<SelectSquare
bind:value={script.includeData}
options={webhookDataLevelOptions}
label="Data Level"
/>
</div>
<div class="pt-1">
<div class="flex items-center gap-2 mb-2">
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">
Events
</p>
<span
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
>
{script.events.length === webhookEventOptions.length
? 'All'
: script.events.length} / {webhookEventOptions.length}
</span>
</div>
<div
class="flex flex-row flex-wrap gap-1.5 max-h-28 overflow-y-auto p-2 bg-white/50 dark:bg-gray-900/30 rounded border border-gray-200 dark:border-gray-700/50"
>
{#each webhookEventDisplayOptions as eventOption}
{@const isSelected = script.events.includes(eventOption.value)}
<button
type="button"
on:click={() => toggleScriptEvent(index, eventOption.value)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-200 {isSelected
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
>
{eventOption.label}
</button>
{/each}
</div>
</div>
</div>
{/each}
<button
type="button"
class="px-4 py-2 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-semibold rounded-md transition-all duration-200"
on:click={addScript}
>
+ Add Script
</button>
</div>
</div>
</div>
{/if}
</ConditionalDisplay>
<ConditionalDisplay show="blackbox">
<div class="mb-6">
<SelectSquare
@@ -2810,6 +2980,29 @@
</div>
{/if}
<ConditionalDisplay show="blackbox">
{#if scriptsEnabled && formValues.scripts.length > 0}
<span class="text-grayblue-dark font-medium">Scripts:</span>
<div class="text-pc-darkblue dark:text-white space-y-2">
{#each formValues.scripts as script, index}
<div class="border-l-2 border-blue-400 pl-3 py-1">
<div class="font-medium">
{scriptMap.byKey(script.id) || 'Not selected'}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Data Level: <span class="capitalize">{script.includeData}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Events: {script.events.length === webhookEventOptions.length
? 'All Events'
: script.events.length + ' selected'}
</div>
</div>
{/each}
</div>
{/if}
</ConditionalDisplay>
{#if formValues.denyPageValue}
<span class="text-grayblue-dark font-medium">Deny Page:</span>
<span class="text-pc-darkblue dark:text-white"
@@ -84,6 +84,7 @@
allowDenyIDs: [],
webhookID: null,
webhooks: [],
scripts: [],
// groups by name, must be mapped to IDs before sending to the server
recipientGroups: [],
events: [],
@@ -94,6 +95,8 @@
};
// map of webhook id -> webhook name for display
let webhookMap = new BiMap({});
// map of script id -> script name for display
let scriptMap = new BiMap({});
let allowedFilter = null;
let campaignRecipients = [];
let campaignRecipientsHasNextPage = false;
@@ -378,6 +381,7 @@
campaign.evasionPage = t.evasionPage;
campaign.webhookID = t.webhookID;
campaign.webhooks = t.webhooks ?? [];
campaign.scripts = t.scripts ?? [];
campaign.companyID = t.companyID;
campaign.company = t.company;
@@ -391,6 +395,19 @@
console.error('failed to load webhooks for display', e);
}
// load script names for display; the endpoint 404s when the feature is
// off, which the catch swallows so the view still renders
if (campaign.scripts.length) {
try {
const allScripts = await fetchAllRows((options) => {
return api.script.getAll(options, campaign.companyID ?? null);
});
scriptMap = BiMap.FromArrayOfObjects(allScripts);
} catch (e) {
console.error('failed to load scripts for display', e);
}
}
// if company exists but name is missing, fetch it
if (campaign.companyID && !campaign.company?.name) {
try {
@@ -2217,6 +2234,21 @@
</div>
</div>
{#if campaign.scripts?.length}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Scripts:</span>
<div class="text-right">
{#each campaign.scripts as s, i}
<a
class="text-cta-blue dark:text-blue-400 hover:underline"
href="/script?edit={s.scriptID}"
target="_blank">{scriptMap.byKey(s.scriptID) || s.scriptID}</a
>{#if i < campaign.scripts.length - 1},&nbsp;{/if}
{/each}
</div>
</div>
{/if}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Data Saving:</span>
<span class="text-pc-darkblue dark:text-white">
+365
View File
@@ -0,0 +1,365 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import { AppStateService } from '$lib/service/appState';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { getModalText } from '$lib/utils/common';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import ScriptEditor from '$lib/components/script/ScriptEditor.svelte';
// services
const appStateService = AppStateService.instance;
// data
let contextCompanyID = null;
let formValues = {
id: '',
name: '',
companyID: '',
script: ''
};
let scripts = [];
let scriptsHasNextPage = true;
// the backend returns 404 on every script endpoint when the feature is off
let featureDisabled = false;
let modalError = '';
const tableURLParams = newTableURLParams();
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = false;
let modalMode = null;
let modalText = '';
// bumped each time the modal opens so the editor remounts with fresh content
let editorKey = 0;
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
$: {
modalText = getModalText('script', modalMode);
}
onMount(() => {
if (appStateService.getContext()) {
contextCompanyID = appStateService.getContext().companyID;
formValues.companyID = contextCompanyID;
}
refreshScripts();
tableURLParams.onChange(refreshScripts);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) {
await openEditModal(editID);
}
})();
return () => {
tableURLParams.unsubscribe();
};
});
const refreshScripts = async () => {
try {
isTableLoading = true;
const result = await getScripts();
scripts = result.rows;
scriptsHasNextPage = result.hasNextPage;
} catch (e) {
console.error(e);
} finally {
isTableLoading = false;
}
};
const getScripts = async () => {
const res = await api.script.getAll(tableURLParams, contextCompanyID);
if (res.success) {
featureDisabled = false;
return res.data;
}
// feature turned off at the server level: show the how-to-enable banner
// instead of an error toast
if (res.statusCode === 404) {
featureDisabled = true;
} else {
addToast('Failed to get scripts', 'Error');
console.error('failed to get scripts', res.error);
}
return { rows: [], hasNextPage: false };
};
const onSubmit = async (event) => {
// FormGrid dispatches saveOnly:true on ctrl/cmd+s so the editor saves
// without closing the modal, matching the remote browser editor
const saveOnly = event?.detail?.saveOnly || false;
try {
isSubmitting = true;
if (modalMode === 'create' || modalMode === 'copy') {
await onClickCreate(saveOnly);
} else {
await onClickUpdate(saveOnly);
}
} finally {
isSubmitting = false;
}
};
const onClickCreate = async (saveOnly = false) => {
try {
const res = await api.script.create(formValues);
if (!res.success) {
modalError = res.error;
return;
}
modalError = '';
refreshScripts();
addToast(saveOnly ? 'Saved script' : 'Created script', 'Success');
if (saveOnly) {
// keep the modal open and switch to update mode so a further save
// edits the same script instead of creating a duplicate
formValues = { ...formValues, id: res.data.id };
modalMode = 'update';
} else {
closeModal();
}
} catch (err) {
addToast('Failed to create script', 'Error');
console.error('failed to create script:', err);
}
};
const onClickUpdate = async (saveOnly = false) => {
try {
const res = await api.script.update(formValues);
if (!res.success) {
modalError = res.error;
return;
}
modalError = '';
refreshScripts();
addToast(saveOnly ? 'Saved script' : 'Updated script', 'Success');
if (!saveOnly) {
closeModal();
}
} catch (err) {
console.error('failed to update script:', err);
}
};
const openDeleteAlert = async (script) => {
isDeleteAlertVisible = true;
deleteValues.id = script.id;
deleteValues.name = script.name;
};
/** @param {string} id */
const onClickDelete = async (id) => {
const action = api.script.delete(id);
action
.then((res) => {
if (res.success) {
refreshScripts();
return;
}
throw res.error;
})
.catch((e) => {
console.error('failed to delete script:', e);
});
return action;
};
const resetForm = () => {
formValues = {
id: '',
name: '',
companyID: contextCompanyID ?? '',
script: ''
};
};
const openCreateModal = () => {
modalMode = 'create';
resetForm();
modalError = '';
editorKey += 1;
isModalVisible = true;
};
/** @param {string} id */
const openEditModal = async (id) => {
modalMode = 'update';
try {
showIsLoading();
const script = await api.script.getByID(id);
if (!script.success) {
throw script.error;
}
const r = globalButtonDisabledAttributes(script, contextCompanyID);
if (r.disabled) {
hideIsLoading();
return;
}
formValues = script.data;
modalError = '';
editorKey += 1;
isModalVisible = true;
} catch (e) {
addToast('Failed to get script', 'Error');
console.error('failed to get script:', e);
} finally {
hideIsLoading();
}
};
const openCopyModal = async (id) => {
modalMode = 'copy';
try {
showIsLoading();
const script = await api.script.getByID(id);
if (!script.success) {
throw script.error;
}
formValues = script.data;
formValues.id = null;
modalError = '';
editorKey += 1;
isModalVisible = true;
} catch (e) {
addToast('Failed to get script', 'Error');
console.error('failed to get script:', e);
} finally {
hideIsLoading();
}
};
const closeModal = () => {
isModalVisible = false;
modalError = '';
};
</script>
<HeadTitle title="Scripts" />
<main>
<Headline>Scripts</Headline>
{#if featureDisabled}
<div class="mt-6 max-w-xl rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8">
<p class="mb-1 text-sm font-semibold text-white">Scripts are not enabled</p>
<p class="mb-3 text-sm text-slate-400">
This feature is disabled by default for security reasons. When enabled, any operator with
access can write scripts that run on the server when campaign events fire — including
making outbound HTTP requests. Only enable it on instances where every operator is trusted
as a server admin.
</p>
<p class="mb-4 text-sm text-slate-400">
To enable it, set <code class="rounded bg-slate-700 px-1 text-slate-200">enabled: true</code>
in the <code class="rounded bg-slate-700 px-1 text-slate-200">script</code> block of
<code class="rounded bg-slate-700 px-1 text-slate-200">config.json</code> and restart the service.
</p>
</div>
{:else}
<BigButton on:click={openCreateModal}>New script</BigButton>
<Table
columns={[
{ column: 'Name', size: 'large' },
...(contextCompanyID ? [{ column: 'Scope', size: 'small' }] : [])
]}
sortable={['name', ...(contextCompanyID ? ['scope'] : [])]}
hasData={!!scripts.length}
hasNextPage={scriptsHasNextPage}
plural="Scripts"
pagination={tableURLParams}
isGhost={isTableLoading}
>
{#each scripts as script}
<TableRow>
<TableCell>
<button
on:click={() => {
openEditModal(script.id);
}}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
title={script.name}
>
{script.name}
</button>
</TableCell>
{#if contextCompanyID}
<TableCellScope companyID={script.companyID} />
{/if}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton
on:click={() => openEditModal(script.id)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
/>
<TableCopyButton
title={'Copy'}
on:click={() => openCopyModal(script.id)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
/>
<TableDeleteButton
on:click={() => openDeleteAlert(script)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
></TableDeleteButton>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
{/if}
<Modal
headerText={modalText}
visible={isModalVisible}
fullscreen={true}
onClose={closeModal}
{isSubmitting}
>
<FormGrid on:submit={onSubmit} {isSubmitting} {modalMode}>
<div class="col-span-3 flex flex-col min-h-0 overflow-hidden px-4 py-4">
{#key editorKey}
<ScriptEditor bind:name={formValues.name} bind:script={formValues.script} />
{/key}
</div>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} okText={modalMode === 'create' ? 'Create' : 'Update'} />
</FormGrid>
</Modal>
<DeleteAlert
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
</main>