add campaign company resource handling

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-02-12 22:47:24 +01:00
parent 2f5d1163d2
commit b43ba4c1b2
4 changed files with 247 additions and 37 deletions
@@ -1,25 +1,44 @@
<script>
import { AppStateService } from '$lib/service/appState';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { showIsLoading } from '$lib/store/loading';
import { resourceContext } from '$lib/store/resourceContext';
let context = {
current: '',
companyName: ''
companyName: '',
companyID: null
};
let resource = {
resourceType: null,
resourceCompanyID: null,
resourceCompanyName: null,
isActive: false
};
const appState = AppStateService.instance;
let unsubAppState;
let unsubResource;
onMount(() => {
const unsub = appState.subscribe((s) => {
unsubAppState = appState.subscribe((s) => {
context = {
current: s.context.current,
companyName: s.context.companyName
companyName: s.context.companyName,
companyID: s.context.companyID
};
});
return () => {
unsub();
};
unsubResource = resourceContext.subscribe((r) => {
resource = { ...r };
});
});
onDestroy(() => {
if (unsubAppState) unsubAppState();
if (unsubResource) unsubResource();
});
// exit to global context
@@ -30,43 +49,125 @@
location.reload();
}
// switch to resource's context
function switchToResourceContext() {
showIsLoading();
if (resource.resourceCompanyID) {
// switch to company context
appState.setCompanyContext(resource.resourceCompanyID, resource.resourceCompanyName);
localStorage.setItem(
'context',
JSON.stringify({ id: resource.resourceCompanyID, name: resource.resourceCompanyName })
);
} else {
// switch to global context
appState.clearContext();
localStorage.setItem('context', '');
}
location.reload();
}
$: isCompanyView = context.current === AppStateService.CONTEXT.COMPANY && context.companyName;
$: isResourceActive = resource.isActive;
$: isResourceGlobal = isResourceActive && !resource.resourceCompanyID;
$: isResourceInDifferentCompany =
isResourceActive &&
resource.resourceCompanyID &&
context.companyID !== resource.resourceCompanyID;
$: hasContextMismatch =
isResourceActive &&
((isCompanyView && isResourceGlobal) ||
(!isCompanyView && resource.resourceCompanyID) ||
isResourceInDifferentCompany);
// determine banner style and content
$: bannerStyle = 'bg-active-blue dark:bg-active-blue';
</script>
{#if isCompanyView}
{#if isCompanyView || hasContextMismatch}
<!-- top banner -->
<div class="w-full h-9 bg-active-blue dark:bg-active-blue z-30 company-banner">
<div class="w-full h-9 {bannerStyle} z-30 company-banner">
<div class="h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-center gap-4 h-full">
<div class="flex items-center space-x-2">
<span class="text-white/70 font-medium text-sm"> Viewing as </span>
<span class="text-white font-semibold text-sm">
{context.companyName}
</span>
</div>
{#if hasContextMismatch}
<!-- context mismatch view -->
<div class="flex items-center space-x-2">
{#if isCompanyView && isResourceGlobal}
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">{context.companyName}</span>
<span class="text-white/70 font-medium text-sm"></span>
<span class="text-white/90 font-medium text-sm">
This {resource.resourceType || 'resource'} is
<strong class="font-bold">global</strong>
</span>
{:else if !isCompanyView && resource.resourceCompanyID}
<span class="text-white/90 font-medium text-sm">
This {resource.resourceType || 'resource'} belongs to
<strong class="font-bold">{resource.resourceCompanyName || 'a company'}</strong>
</span>
{:else if isResourceInDifferentCompany}
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">{context.companyName}</span>
<span class="text-white/70 font-medium text-sm"></span>
<span class="text-white/90 font-medium text-sm">
This {resource.resourceType || 'resource'} belongs to
<strong class="font-bold"
>{resource.resourceCompanyName || 'another company'}</strong
>
</span>
{/if}
<!-- exit button -->
<button
on:click={exitCompanyView}
class="flex items-center gap-1 px-2 py-0.5 text-white/50 hover:text-white/80 text-xs transition-colors duration-200"
title="Exit company view"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<!-- switch button -->
<button
on:click={switchToResourceContext}
class="flex items-center gap-1.5 px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded text-xs font-semibold transition-colors duration-200"
title="Switch to {isResourceGlobal
? 'global'
: resource.resourceCompanyName || 'company'} context"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
></path>
</svg>
Switch
</button>
</div>
{:else}
<!-- normal company view -->
<div class="flex items-center space-x-2">
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">
{context.companyName}
</span>
</div>
<!-- exit button -->
<button
on:click={exitCompanyView}
class="flex items-center gap-1 px-2 py-0.5 text-white/50 hover:text-white/80 text-xs transition-colors duration-200"
title="Exit company view"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- border frame around entire viewport when in company view -->
{#if isCompanyView}
<!-- border frame around entire viewport when in company view or context mismatch -->
{#if isCompanyView || hasContextMismatch}
<div class="company-view-frame"></div>
{/if}
@@ -81,7 +182,7 @@
left: 0;
right: 0;
bottom: 0;
border: 3px solid #1e3fa8;
border: 3px solid;
pointer-events: none;
z-index: 9999;
}
+45
View File
@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
/**
* store for tracking the current resource being viewed
* this allows the company banner to show context mismatch information
*/
function createResourceContextStore() {
const { subscribe, set, update } = writable({
resourceType: null, // e.g., 'campaign', 'template', 'email'
resourceCompanyID: null,
resourceCompanyName: null,
isActive: false
});
return {
subscribe,
/**
* set the current resource context
* @param {string} type - resource type (e.g., 'campaign', 'template')
* @param {string|null} companyID - company id the resource belongs to (null for global)
* @param {string|null} companyName - company name (null for global)
*/
setResource: (type, companyID, companyName) => {
set({
resourceType: type,
resourceCompanyID: companyID,
resourceCompanyName: companyName,
isActive: true
});
},
/**
* clear the resource context (call when leaving a resource page)
*/
clear: () => {
set({
resourceType: null,
resourceCompanyID: null,
resourceCompanyName: null,
isActive: false
});
}
};
}
export const resourceContext = createResourceContextStore();
@@ -37,6 +37,7 @@
import CopyCell from '$lib/components/table/CopyCell.svelte';
import TextFieldSelectWithType from '$lib/components/form/TextFieldSelectWithType.svelte';
import ConditionalDisplay from '$lib/components/ConditionalDisplay.svelte';
import { resourceContext } from '$lib/store/resourceContext';
// services
const appStateService = AppStateService.instance;
@@ -135,8 +136,11 @@
}
hideIsLoading();
})();
// cleanup resource context when leaving page
return () => {
tableURLParams.unsubscribe();
resourceContext.clear();
};
});
@@ -403,14 +407,13 @@
const closeModal = () => {
isModalVisible = false;
form?.reset();
formValues = {
id: null,
templateType: 'Email',
name: null,
domain: null,
landingPage: null,
landingPageType: 'page',
landingPageType: 'page', // 'page' or 'proxy'
beforeLandingPage: null,
afterLandingPage: null,
afterLandingPageRedirectURL: '',
@@ -421,6 +424,10 @@
stateIdentifier: 'session',
urlPath: ''
};
modalError = '';
showAdvancedOptions = false;
// clear resource context when closing modal
resourceContext.clear();
};
/** @param {string} id */
@@ -432,10 +439,26 @@
const r = globalButtonDisabledAttributes(template, contextCompanyID);
if (r.disabled) {
hideIsLoading();
resourceContext.clear();
return;
}
assignTemplate(template);
// if company exists but name is missing, fetch it
if (template.companyID && !template.company?.name) {
try {
const companyRes = await api.company.getByID(template.companyID);
if (companyRes.success && companyRes.data) {
template.company = companyRes.data;
}
} catch (e) {
console.error('failed to load company details', e);
}
}
// set resource context for banner
resourceContext.setResource('template', template.companyID, template.company?.name);
isModalVisible = true;
} catch (e) {
addToast('Failed to load campaign template', 'Error');
@@ -452,6 +475,21 @@
const template = await getTemplate(id);
assignTemplate(template);
formValues.id = null;
// if company exists but name is missing, fetch it
if (template.companyID && !template.company?.name) {
try {
const companyRes = await api.company.getByID(template.companyID);
if (companyRes.success && companyRes.data) {
template.company = companyRes.data;
}
} catch (e) {
console.error('failed to load company details', e);
}
}
// set resource context for banner
resourceContext.setResource('template', template.companyID, template.company?.name);
isModalVisible = true;
} catch (e) {
addToast('Failed to load campaign template', 'Error');
@@ -513,8 +551,9 @@
</script>
<HeadTitle title="Campaigns templates" />
<main>
<Headline>Campaign templates</Headline>
<Headline>Campaigns templates</Headline>
<BigButton on:click={openCreateModal}>New template</BigButton>
<Table
+26 -1
View File
@@ -48,6 +48,7 @@
import papaparse from 'papaparse';
import FormFooter from '$lib/components/FormFooter.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { resourceContext } from '$lib/store/resourceContext';
// services
const appStateService = AppStateService.instance;
@@ -76,7 +77,9 @@
recipientGroups: [],
events: [],
eventTypesIDToNameMap: {},
notableEventName: ''
notableEventName: '',
companyID: null,
company: null
};
let allowedFilter = null;
let campaignRecipients = [];
@@ -167,6 +170,7 @@
if (context) {
contextCompanyID = context.companyID;
}
(async () => {
await refresh();
recipientTableUrlParams.onChange(refreshRecipients);
@@ -176,9 +180,12 @@
await refreshRecipientsTimes();
await refreshCampaignEventsSince();
})();
// cleanup resource context when leaving page
return () => {
recipientTableUrlParams.unsubscribe();
eventsTableURLParams.unsubscribe();
resourceContext.clear();
};
});
@@ -240,6 +247,21 @@
campaign.denyPage = t.denyPage;
campaign.evasionPage = t.evasionPage;
campaign.webhookID = t.webhookID;
campaign.companyID = t.companyID;
campaign.company = t.company;
// if company exists but name is missing, fetch it
if (campaign.companyID && !campaign.company?.name) {
try {
const companyRes = await api.company.getByID(campaign.companyID);
if (companyRes.success && companyRes.data) {
campaign.company = companyRes.data;
}
} catch (e) {
console.error('failed to load company details', e);
}
}
// fetch the full template object
if (t.templateID) {
const templateRes = await api.campaignTemplate.getByID(t.templateID, true);
@@ -255,6 +277,9 @@
if (campaign.allowDeny && campaign.allowDeny[0]) {
allowedFilter = campaign.allowDeny[0].allowed;
}
// set resource context for banner
resourceContext.setResource('campaign', campaign.companyID, campaign.company?.name);
} catch (e) {
addToast('Failed to load campaign', 'Error');
console.error('failed to load campaign', e);