mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-06-08 07:33:52 +02:00
improve UI of settings
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
// selectable bordered radio row with label and optional description
|
||||
export let checked = false;
|
||||
export let label = '';
|
||||
export let description = '';
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {checked
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
{checked}
|
||||
on:change
|
||||
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div class="text-left flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">{label}</span>
|
||||
{#if description}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">{description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
// reusable settings panel card: title, body slot, optional footer slot.
|
||||
// fixed comfortable width so panels can wrap cards consistently.
|
||||
export let title = '';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 flex flex-col w-full sm:w-96 transition-colors duration-200"
|
||||
>
|
||||
<h2
|
||||
class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-6 transition-colors duration-200"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div class="flex flex-col flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
{#if $$slots.footer}
|
||||
<div class="mt-auto pt-6 flex justify-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
// inline loading indicator shown while a settings panel fetches its data
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 py-8">
|
||||
<div
|
||||
class="w-5 h-5 border-t-2 border-t-cta-blue border-r-2 border-r-cta-blue border-b-cta-blue border-b-2 border-l-transparent border-l-2 rounded-full animate-spin"
|
||||
></div>
|
||||
Loading...
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import PasswordField from '$lib/components/PasswordField.svelte';
|
||||
|
||||
let loaded = false;
|
||||
let ssoForm = null;
|
||||
let isSSOModalVisible = false;
|
||||
let isSSODeleteAlertVisible = false;
|
||||
let updateSSOError = '';
|
||||
let isSubmitting = false;
|
||||
let isSSOEnabled = false;
|
||||
let ssoSettingsFormValues = {
|
||||
clientID: null,
|
||||
tenantID: null,
|
||||
redirectURL: null,
|
||||
clientSecret: null
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshSSO();
|
||||
if (!ssoSettingsFormValues.redirectURL) {
|
||||
ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`;
|
||||
}
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshSSO() {
|
||||
try {
|
||||
const res = await api.option.get('sso_login');
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
const sso = JSON.parse(res.data.value);
|
||||
sso.clientSecret = '';
|
||||
ssoSettingsFormValues = sso;
|
||||
isSSOEnabled = sso.enabled;
|
||||
} catch (e) {
|
||||
console.error('failed to get SSO configuration', e);
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmitSSO = async () => {
|
||||
updateSSOError = '';
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const res = await api.sso.upsert(ssoSettingsFormValues);
|
||||
if (!res.success) {
|
||||
updateSSOError = res.error;
|
||||
return;
|
||||
}
|
||||
closeSSOModal();
|
||||
refreshSSO();
|
||||
} catch (e) {
|
||||
addToast('Failed to update SSO configuration', 'Error');
|
||||
console.error('failed to update SSO configuration', e);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openSSOModal = async (e) => {
|
||||
e.preventDefault();
|
||||
isSSOModalVisible = true;
|
||||
};
|
||||
|
||||
const closeSSOModal = () => {
|
||||
updateSSOError = '';
|
||||
ssoSettingsFormValues = {
|
||||
clientID: null,
|
||||
tenantID: null,
|
||||
redirectURL: null,
|
||||
clientSecret: null
|
||||
};
|
||||
isSSOModalVisible = false;
|
||||
};
|
||||
|
||||
const onClickDisableSSO = async () => {
|
||||
const action = api.sso.upsert({
|
||||
clientID: '',
|
||||
tenantID: '',
|
||||
redirectURL: '',
|
||||
clientSecret: ''
|
||||
});
|
||||
action
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
refreshSSO();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('failed to remove SSO configuration:', e);
|
||||
});
|
||||
return action;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Single Sign-On">
|
||||
<div class="bg-gray-50 rounded-md p-3">
|
||||
{#if isSSOEnabled}
|
||||
<p class="text-sm font-medium text-green-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Enabled
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
|
||||
Disabled
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if isSSOEnabled}
|
||||
<Button
|
||||
size={'large'}
|
||||
on:click={() => {
|
||||
isSSODeleteAlertVisible = true;
|
||||
}}>Disable SSO</Button
|
||||
>
|
||||
{:else}
|
||||
<Button size={'large'} on:click={openSSOModal}>Configure SSO</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isSSOModalVisible}
|
||||
<Modal bind:visible={isSSOModalVisible} headerText="SSO configuration" onClose={closeSSOModal}>
|
||||
<div class="mt-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-white">Microsoft SSO Setup</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Configure Single Sign-On with Microsoft Azure AD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||
<p class="font-semibold text-gray-900 dark:text-white mb-2">Important:</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Accounts that login with SSO will no longer be able to use password login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FormGrid on:submit={onSubmitSSO} bind:bindTo={ssoForm} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.clientID}
|
||||
placeholder="e.g., 8adf8e7c-d3ef-4a1b-b6c5-12345678abcd">Client ID</TextField
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.tenantID}
|
||||
placeholder="e.g., contoso.onmicrosoft.com">Tenant ID</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
type="url"
|
||||
bind:value={ssoSettingsFormValues.redirectURL}
|
||||
placeholder="https://your-domain.com/auth/callback">Redirect URL</TextField
|
||||
>
|
||||
|
||||
<PasswordField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.clientSecret}
|
||||
placeholder="Enter your client secret">Client Secret</PasswordField
|
||||
>
|
||||
</FormColumn>
|
||||
<FormError message={updateSSOError} />
|
||||
</FormColumns>
|
||||
<FormFooter closeModal={closeSSOModal} okText="Enable SSO" closeText="Cancel" {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if isSSODeleteAlertVisible}
|
||||
<DeleteAlert
|
||||
list={[
|
||||
'SSO will be disabled',
|
||||
'Configuration will be deleted',
|
||||
'SSO users will no longer be able to log in',
|
||||
'Be sure there is a administrative user without SSO'
|
||||
]}
|
||||
confirm
|
||||
name={'SSO configuration'}
|
||||
onClick={() => onClickDisableSSO()}
|
||||
bind:isVisible={isSSODeleteAlertVisible}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,588 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import RadioOption from '$lib/components/RadioOption.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FileField from '$lib/components/FileField.svelte';
|
||||
import FormButton from '$lib/components/FormButton.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// auto-prune settings
|
||||
let autoPruneOption = { enabled: false, companies: [] };
|
||||
let autoPruneEnabled = false;
|
||||
let autoPruneError = '';
|
||||
|
||||
// backup
|
||||
let isBackupModalVisible = false;
|
||||
let isCreatingBackup = false;
|
||||
let availableBackups = [];
|
||||
let isLoadingBackups = false;
|
||||
|
||||
// import
|
||||
let importError = '';
|
||||
let isImportSubmitting = false;
|
||||
let importFile = null;
|
||||
let importResult = null;
|
||||
let isImportResultModalVisible = false;
|
||||
let importModalContent = null;
|
||||
|
||||
// company context for import
|
||||
const appState = AppStateService.instance;
|
||||
let isCompanyContext = false;
|
||||
let importForCompany = false;
|
||||
let contextCompanyID = null;
|
||||
|
||||
$: {
|
||||
isCompanyContext = appState.isCompanyContext();
|
||||
importForCompany = isCompanyContext;
|
||||
if (appState.getContext()) {
|
||||
contextCompanyID = appState.getContext().companyID;
|
||||
} else {
|
||||
contextCompanyID = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshAutoPrune();
|
||||
await refreshBackupList();
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshAutoPrune() {
|
||||
try {
|
||||
const res = await api.option.getAutoPrune();
|
||||
if (res.success) {
|
||||
autoPruneOption = res.data;
|
||||
autoPruneEnabled = res.data.enabled === true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to load auto-prune setting', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutoPruneValue(enabled) {
|
||||
autoPruneError = '';
|
||||
// read-modify-write: preserve per-company entries
|
||||
const updated = { ...autoPruneOption, enabled };
|
||||
try {
|
||||
const res = await api.option.setAutoPrune(updated);
|
||||
if (!res.success) {
|
||||
autoPruneError = res.error;
|
||||
return;
|
||||
}
|
||||
autoPruneOption = updated;
|
||||
autoPruneEnabled = enabled;
|
||||
addToast('Auto-prune setting saved', 'Success');
|
||||
} catch (e) {
|
||||
autoPruneError = 'Failed to save auto-prune setting';
|
||||
console.error('failed to set auto-prune setting', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBackupList() {
|
||||
isLoadingBackups = true;
|
||||
try {
|
||||
const res = await api.application.listBackups();
|
||||
if (res.success) {
|
||||
availableBackups = res.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to refresh backup list', e);
|
||||
availableBackups = [];
|
||||
} finally {
|
||||
isLoadingBackups = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(filename) {
|
||||
try {
|
||||
const blob = await api.application.downloadBackup(filename);
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
addToast('Backup downloaded', 'Success');
|
||||
} catch (e) {
|
||||
console.error('failed to download backup', e);
|
||||
addToast('Failed to download backup', 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
const openBackupModal = () => {
|
||||
isBackupModalVisible = true;
|
||||
};
|
||||
|
||||
const closeBackupModal = () => {
|
||||
isBackupModalVisible = false;
|
||||
};
|
||||
|
||||
async function createBackup() {
|
||||
isCreatingBackup = true;
|
||||
try {
|
||||
const res = await api.application.createBackup();
|
||||
if (res.success) {
|
||||
addToast('Backup created', 'Success');
|
||||
closeBackupModal();
|
||||
await refreshBackupList();
|
||||
} else {
|
||||
addToast('Failed to create backup', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to create backup', e);
|
||||
addToast('Failed to create backup', 'Error');
|
||||
} finally {
|
||||
isCreatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onSetImportFile = (event) => {
|
||||
importFile = event.target.files[0];
|
||||
};
|
||||
|
||||
const onSubmitImport = async () => {
|
||||
if (!importFile) {
|
||||
importError = 'Please select a file to import';
|
||||
return;
|
||||
}
|
||||
|
||||
isImportSubmitting = true;
|
||||
importError = '';
|
||||
importResult = null;
|
||||
isImportResultModalVisible = false;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
formData.append('forCompany', importForCompany ? '1' : '0');
|
||||
if (importForCompany && contextCompanyID) {
|
||||
formData.append('companyID', contextCompanyID);
|
||||
}
|
||||
const response = await api.import.import(formData);
|
||||
|
||||
if (response.success) {
|
||||
addToast('File has been imported', 'Success');
|
||||
importFile = null;
|
||||
importResult = response.data;
|
||||
isImportResultModalVisible = true;
|
||||
setTimeout(() => {
|
||||
if (importModalContent) {
|
||||
importModalContent.scrollTop = 0;
|
||||
}
|
||||
}, 0);
|
||||
const fileInput = document.querySelector('input[type="file"][name="importFile"]');
|
||||
if (fileInput) /** @type {HTMLInputElement} */ (fileInput).value = '';
|
||||
} else {
|
||||
importError = response.error || 'Import failed';
|
||||
importResult = response.data || null;
|
||||
isImportResultModalVisible = !!importResult;
|
||||
setTimeout(() => {
|
||||
if (importModalContent) {
|
||||
importModalContent.scrollTop = 0;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
importError = 'An error occurred during import';
|
||||
importResult = null;
|
||||
isImportResultModalVisible = false;
|
||||
} finally {
|
||||
isImportSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Import">
|
||||
<div class="space-y-4">
|
||||
<FileField name="importFile" accept=".zip" on:change={(e) => onSetImportFile(e)}>
|
||||
Select ZIP file to import
|
||||
</FileField>
|
||||
<label class="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" bind:checked={importForCompany} disabled={!isCompanyContext} />
|
||||
Import pages and emails as company templates
|
||||
</label>
|
||||
{#if importForCompany}
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md text-sm text-blue-700 dark:text-blue-200 transition-colors duration-200"
|
||||
>
|
||||
<strong>Company Import:</strong><br /> Pages and emails will be imported for this company. Assets
|
||||
will be imported as global/shared resources.
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 p-3 rounded-md text-sm text-gray-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
<strong>Global Import:</strong> All templates and assets will be imported as shared resources.
|
||||
</div>
|
||||
{/if}
|
||||
<FormError message={importError} />
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
<FormButton
|
||||
size={'large'}
|
||||
isSubmitting={isImportSubmitting}
|
||||
on:click={importFile ? onSubmitImport : undefined}
|
||||
>
|
||||
{#if isImportSubmitting}
|
||||
Importing...
|
||||
{:else}
|
||||
Import File
|
||||
{/if}
|
||||
</FormButton>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Backup">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Create a backup of database, assets, attachments and certificates.
|
||||
</p>
|
||||
|
||||
{#if availableBackups.length > 0}
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded-md transition-colors duration-200">
|
||||
<h4
|
||||
class="font-medium text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"
|
||||
>
|
||||
Available:
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
{#each availableBackups as backup}
|
||||
<div class="flex items-start justify-between gap-4 text-sm">
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span
|
||||
class="text-gray-700 dark:text-gray-200 text-xs font-medium transition-colors duration-200"
|
||||
>
|
||||
{new Date(backup.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
class="text-gray-400 dark:text-gray-400 text-xs transition-colors duration-200"
|
||||
>
|
||||
{(backup.size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800 text-white text-xs rounded transition-colors flex-shrink-0"
|
||||
on:click={() => downloadBackup(backup.name)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isLoadingBackups}
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 p-3 rounded-md text-sm text-gray-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
No backups available yet.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size={'large'} on:click={openBackupModal} disabled={isCreatingBackup}>
|
||||
Create Backup
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Auto-Prune Recipients">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Automatically delete orphaned recipients (not in any group) on a hourly schedule.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<RadioOption
|
||||
checked={autoPruneEnabled}
|
||||
label="Enabled"
|
||||
description="Orphaned recipients are deleted automatically each hour"
|
||||
on:change={() => setAutoPruneValue(true)}
|
||||
/>
|
||||
<RadioOption
|
||||
checked={!autoPruneEnabled}
|
||||
label="Disabled"
|
||||
description="Orphaned recipients are kept until manually deleted"
|
||||
on:change={() => setAutoPruneValue(false)}
|
||||
/>
|
||||
</div>
|
||||
<FormError message={autoPruneError} />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isImportResultModalVisible && importResult}
|
||||
<Modal headerText="Import Summary" bind:visible={isImportResultModalVisible}>
|
||||
<div
|
||||
class="p-6 max-h-[80vh] overflow-y-auto bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
bind:this={importModalContent}
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"
|
||||
>
|
||||
Assets (Global/Shared)
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li>Created: {importResult.assets_created}</li>
|
||||
<li>Skipped: {importResult.assets_skipped}</li>
|
||||
<li>Errors: {importResult.assets_errors}</li>
|
||||
</ul>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 transition-colors duration-200">
|
||||
Assets are always imported as global resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"
|
||||
>
|
||||
Pages
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li>Created: {importResult.pages_created}</li>
|
||||
<li>Updated: {importResult.pages_updated}</li>
|
||||
<li>Skipped: {importResult.pages_skipped}</li>
|
||||
<li>Errors: {importResult.pages_errors}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"
|
||||
>
|
||||
Emails
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li>Created: {importResult.emails_created}</li>
|
||||
<li>Updated: {importResult.emails_updated}</li>
|
||||
<li>Skipped: {importResult.emails_skipped}</li>
|
||||
<li>Errors: {importResult.emails_errors}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-6">
|
||||
<div class="space-y-6">
|
||||
{#if importResult.assets_skipped_list?.length > 0}
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Assets (Global/Shared)</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">
|
||||
All assets are imported as global resources regardless of import context
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
{#if importResult.assets_skipped_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Skipped:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.assets_skipped_list || [] as asset}
|
||||
<li>{asset}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.assets_errors_list?.length > 0}
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<h4 class="font-semibold text-red-600 mb-1">Errors:</h4>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm">
|
||||
{#each importResult.assets_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.pages_created_list?.length > 0 || importResult.pages_updated_list?.length > 0 || importResult.pages_skipped_list?.length > 0 || importResult.pages_errors_list?.length > 0}
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Pages</h3>
|
||||
<div class="space-y-4">
|
||||
{#if importResult.pages_created_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Created:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.pages_created_list || [] as page}
|
||||
<li>{page}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.pages_updated_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Updated:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.pages_updated_list || [] as page}
|
||||
<li>{page}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.pages_skipped_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Skipped:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.pages_skipped_list || [] as page}
|
||||
<li>{page}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.pages_errors_list?.length > 0}
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<h4 class="font-semibold text-red-600 mb-1">Errors:</h4>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm">
|
||||
{#each importResult.pages_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importResult.emails_created_list?.length > 0 || importResult.emails_updated_list?.length > 0 || importResult.emails_errors_list?.length > 0 || importResult.emails_skipped_list?.length > 0}
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Emails</h3>
|
||||
<div class="space-y-4">
|
||||
{#if importResult.emails_created_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Created:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.emails_created_list || [] as email}
|
||||
<li>{email}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.emails_updated_list?.length > 0}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-medium mb-1">Updated:</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{#each importResult.emails_updated_list || [] as email}
|
||||
<li>{email}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if importResult.emails_errors_list?.length > 0}
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<h4 class="font-semibold text-red-600 mb-1">Errors:</h4>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm">
|
||||
{#each importResult.emails_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if importResult.errors && importResult.errors.length > 0}
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<h4 class="font-semibold text-red-600 mb-1">Errors:</h4>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm">
|
||||
{#each importResult.errors as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button on:click={() => (isImportResultModalVisible = false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if isBackupModalVisible}
|
||||
<Modal
|
||||
headerText="Create Backup"
|
||||
bind:visible={isBackupModalVisible}
|
||||
onClose={closeBackupModal}
|
||||
isSubmitting={isCreatingBackup}
|
||||
>
|
||||
<FormGrid on:submit={createBackup} isSubmitting={isCreatingBackup}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<div class="space-y-4">
|
||||
<p>This will create a backup file that can be downloaded from the settings page.</p>
|
||||
<p>
|
||||
<strong>Note:</strong> This is not a substitute for having proper automated and tested backup
|
||||
and recovery plans at the operating system level.
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">What will be backed up:</h3>
|
||||
<ul class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>• SQLite database (including WAL files)</li>
|
||||
<li>• Asset files</li>
|
||||
<li>• Attachment files</li>
|
||||
<li>• Certificate files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Important:</h3>
|
||||
<ul class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>• Large databases may take significant time to backup</li>
|
||||
<li>• Operations may be affected during the backup process</li>
|
||||
<li>• Ensure you have sufficient disk space</li>
|
||||
<li>
|
||||
• Only the 3 most recent backups are kept (older ones are automatically deleted)
|
||||
</li>
|
||||
<li>• The backup does not include config.json or the application binary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormFooter
|
||||
closeModal={closeBackupModal}
|
||||
isSubmitting={isCreatingBackup}
|
||||
okText={isCreatingBackup ? 'Creating Backup...' : 'Create Backup'}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { immediateResponseHandler } from '$lib/api/middleware.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { hideIsLoading, showIsLoading } from '$lib/store/loading';
|
||||
import { displayMode, DISPLAY_MODE } from '$lib/store/displayMode';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import RadioOption from '$lib/components/RadioOption.svelte';
|
||||
import Form from '$lib/components/Form.svelte';
|
||||
import FormButton from '$lib/components/FormButton.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
|
||||
let loaded = false;
|
||||
let currentDisplayMode = DISPLAY_MODE.WHITEBOX;
|
||||
let displayModeError = '';
|
||||
|
||||
let formValues = {
|
||||
maxFileSize: null,
|
||||
repeatOffenderMonths: null
|
||||
};
|
||||
let updateSettingsError = '';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshDisplayMode();
|
||||
await refreshSettings();
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshDisplayMode() {
|
||||
try {
|
||||
const res = immediateResponseHandler(await api.option.get('display_mode'));
|
||||
if (res.success && res.data.value) {
|
||||
currentDisplayMode = res.data.value;
|
||||
displayMode.setMode(res.data.value);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to refresh display mode', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDisplayMode(mode) {
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.option.set('display_mode', mode);
|
||||
if (res.success) {
|
||||
currentDisplayMode = mode;
|
||||
displayMode.setMode(mode);
|
||||
addToast('Display mode updated', 'Success');
|
||||
displayModeError = '';
|
||||
} else {
|
||||
displayModeError = res.error || 'Failed to update display mode';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to set display mode', e);
|
||||
displayModeError = 'Failed to update display mode';
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
try {
|
||||
const res = immediateResponseHandler(await api.option.get('max_file_upload_size_mb'));
|
||||
if (res.success) {
|
||||
formValues.maxFileSize = res.data.value;
|
||||
} else {
|
||||
throw res.error;
|
||||
}
|
||||
const resRepeat = immediateResponseHandler(await api.option.get('repeat_offender_months'));
|
||||
if (resRepeat.success) {
|
||||
formValues.repeatOffenderMonths = resRepeat.data.value;
|
||||
} else {
|
||||
throw resRepeat.error;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const onClickUpdateSettings = async () => {
|
||||
updateSettingsError = '';
|
||||
try {
|
||||
const res = await api.option.set('max_file_upload_size_mb', formValues.maxFileSize);
|
||||
if (!res.success) {
|
||||
updateSettingsError = res.error;
|
||||
return;
|
||||
}
|
||||
const resRepeat = await api.option.set(
|
||||
'repeat_offender_months',
|
||||
formValues.repeatOffenderMonths
|
||||
);
|
||||
if (!resRepeat.success) {
|
||||
updateSettingsError = resRepeat.error;
|
||||
return;
|
||||
}
|
||||
addToast('Settings updated', 'Success');
|
||||
} catch (e) {
|
||||
addToast('Failed to update settings', 'Error');
|
||||
console.error('failed to update settings', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Display Mode">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Select which features are available
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<RadioOption
|
||||
checked={currentDisplayMode === DISPLAY_MODE.WHITEBOX}
|
||||
label="Phishing Simulation"
|
||||
on:change={() => setDisplayMode(DISPLAY_MODE.WHITEBOX)}
|
||||
/>
|
||||
<RadioOption
|
||||
checked={currentDisplayMode === DISPLAY_MODE.BLACKBOX}
|
||||
label="Red Team Phishing"
|
||||
on:change={() => setDisplayMode(DISPLAY_MODE.BLACKBOX)}
|
||||
/>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Read about the difference between <a
|
||||
class="white underline"
|
||||
href="https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/"
|
||||
target="_blank">phishing simulation and red team phishing</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<FormError message={displayModeError} />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="General Settings">
|
||||
<Form on:submit={onClickUpdateSettings} fullWidth>
|
||||
<TextField required width="full" type="number" min="1" bind:value={formValues.maxFileSize}
|
||||
>Upload max file size (MB)</TextField
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
width="full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
bind:value={formValues.repeatOffenderMonths}>Repeat Offender Memory (Months)</TextField
|
||||
>
|
||||
<FormError message={updateSettingsError} />
|
||||
<div class="mt-6 flex justify-end">
|
||||
<FormButton size={'medium'}>Save Changes</FormButton>
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,124 @@
|
||||
<script>
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { hideIsLoading, showIsLoading } from '$lib/store/loading';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
|
||||
|
||||
let isObfuscationTemplateModalVisible = false;
|
||||
let obfuscationTemplate = '';
|
||||
let obfuscationTemplateError = '';
|
||||
let isObfuscationTemplateSubmitting = false;
|
||||
|
||||
const openObfuscationTemplateModal = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
const response = await api.option.get('obfuscation_template');
|
||||
if (response.success) {
|
||||
obfuscationTemplate = response.data.value || '';
|
||||
} else {
|
||||
obfuscationTemplateError = 'Failed to load template';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load obfuscation template:', error);
|
||||
obfuscationTemplateError = 'Failed to load template';
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
isObfuscationTemplateModalVisible = true;
|
||||
}
|
||||
};
|
||||
|
||||
const closeObfuscationTemplateModal = () => {
|
||||
isObfuscationTemplateModalVisible = false;
|
||||
obfuscationTemplateError = '';
|
||||
};
|
||||
|
||||
const onSubmitObfuscationTemplate = async (event) => {
|
||||
const saveOnly = event?.detail?.saveOnly || false;
|
||||
isObfuscationTemplateSubmitting = true;
|
||||
obfuscationTemplateError = '';
|
||||
|
||||
try {
|
||||
const response = await api.option.set('obfuscation_template', obfuscationTemplate);
|
||||
|
||||
if (response.success) {
|
||||
addToast(
|
||||
saveOnly ? 'Obfuscation template saved' : 'Obfuscation template updated',
|
||||
'Success'
|
||||
);
|
||||
if (!saveOnly) {
|
||||
isObfuscationTemplateModalVisible = false;
|
||||
}
|
||||
} else {
|
||||
obfuscationTemplateError = response.error || 'Failed to update template';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update obfuscation template:', error);
|
||||
obfuscationTemplateError = 'Failed to update template';
|
||||
} finally {
|
||||
isObfuscationTemplateSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Obfuscation Template">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Customize the template used when obfuscation is enabled to.
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded-md transition-colors duration-200">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Internal obfuscation variable:</strong>
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 font-mono">
|
||||
{'{{.Script}}'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size={'large'} on:click={openObfuscationTemplateModal}>Edit Template</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{#if isObfuscationTemplateModalVisible}
|
||||
<Modal
|
||||
bind:visible={isObfuscationTemplateModalVisible}
|
||||
headerText="Edit Obfuscation Template"
|
||||
onClose={closeObfuscationTemplateModal}
|
||||
>
|
||||
<FormGrid
|
||||
on:submit={onSubmitObfuscationTemplate}
|
||||
isSubmitting={isObfuscationTemplateSubmitting}
|
||||
modalMode="update"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<SimpleCodeEditor
|
||||
bind:value={obfuscationTemplate}
|
||||
language="html"
|
||||
height="large"
|
||||
showVimToggle={true}
|
||||
showExpandButton={false}
|
||||
/>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 my-4">
|
||||
Example <code class="bg-gray-200 dark:bg-gray-700 p-1 rounded text-xs"
|
||||
>{"eval(atob('{{base64 .Script}}'))"}</code
|
||||
>
|
||||
</p>
|
||||
<FormError message={obfuscationTemplateError} />
|
||||
</div>
|
||||
<FormFooter
|
||||
isSubmitting={isObfuscationTemplateSubmitting}
|
||||
closeModal={closeObfuscationTemplateModal}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,241 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { hideIsLoading, showIsLoading } from '$lib/store/loading';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Editor from '$lib/components/editor/Editor.svelte';
|
||||
|
||||
const appState = AppStateService.instance;
|
||||
$: isCompanyContext = appState.isCompanyContext();
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// PDF reports
|
||||
let isReportPDFEnabled = false;
|
||||
let isReportPDFEnableModalVisible = false;
|
||||
let isTogglingReportPDF = false;
|
||||
|
||||
// report template
|
||||
let isReportTemplateModalVisible = false;
|
||||
let reportTemplateContent = '';
|
||||
let reportTemplateID = null;
|
||||
let reportTemplateError = '';
|
||||
let isReportTemplateSubmitting = false;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshReportPDFEnabled();
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
const refreshReportPDFEnabled = async () => {
|
||||
const response = await api.option.get('report_pdf_enabled');
|
||||
isReportPDFEnabled = response.success && response.data?.value === 'true';
|
||||
};
|
||||
|
||||
const onClickReportPDFToggle = () => {
|
||||
if (isReportPDFEnabled) {
|
||||
onDisableReportPDF();
|
||||
} else {
|
||||
isReportPDFEnableModalVisible = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onDisableReportPDF = async () => {
|
||||
isTogglingReportPDF = true;
|
||||
try {
|
||||
const response = await api.option.set('report_pdf_enabled', 'false');
|
||||
if (response.success) {
|
||||
isReportPDFEnabled = false;
|
||||
addToast('PDF reports disabled', 'Success');
|
||||
} else {
|
||||
addToast(response.error || 'Failed to update setting', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to update setting', 'Error');
|
||||
} finally {
|
||||
isTogglingReportPDF = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmEnableReportPDF = async () => {
|
||||
isTogglingReportPDF = true;
|
||||
try {
|
||||
const response = await api.option.set('report_pdf_enabled', 'true');
|
||||
if (response.success) {
|
||||
isReportPDFEnabled = true;
|
||||
isReportPDFEnableModalVisible = false;
|
||||
addToast('PDF reports enabled', 'Success');
|
||||
} else {
|
||||
addToast(response.error || 'Failed to update setting', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to update setting', 'Error');
|
||||
} finally {
|
||||
isTogglingReportPDF = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openReportTemplateModal = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
reportTemplateContent = '';
|
||||
reportTemplateID = null;
|
||||
reportTemplateError = '';
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load report template:', error);
|
||||
reportTemplateError = 'Failed to load template';
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
isReportTemplateModalVisible = true;
|
||||
}
|
||||
};
|
||||
|
||||
const closeReportTemplateModal = () => {
|
||||
isReportTemplateModalVisible = false;
|
||||
reportTemplateError = '';
|
||||
};
|
||||
|
||||
const onSubmitReportTemplate = async (event) => {
|
||||
const saveOnly = event?.detail?.saveOnly || false;
|
||||
isReportTemplateSubmitting = true;
|
||||
reportTemplateError = '';
|
||||
try {
|
||||
let response;
|
||||
if (reportTemplateID) {
|
||||
response = await api.reportTemplate.update(reportTemplateID, {
|
||||
content: reportTemplateContent
|
||||
});
|
||||
} else {
|
||||
response = await api.reportTemplate.create({ content: reportTemplateContent });
|
||||
if (response.success && response.data?.id) {
|
||||
reportTemplateID = response.data.id;
|
||||
}
|
||||
}
|
||||
if (response.success) {
|
||||
addToast('Report template saved', 'Success');
|
||||
if (!saveOnly) {
|
||||
isReportTemplateModalVisible = false;
|
||||
}
|
||||
} else {
|
||||
reportTemplateError = response.error || 'Failed to save template';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save report template:', error);
|
||||
reportTemplateError = 'Failed to save template';
|
||||
} finally {
|
||||
isReportTemplateSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="PDF Reports">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Generate PDF reports for campaigns. Requires Chromium and system dependencies.
|
||||
</p>
|
||||
<p
|
||||
class="text-sm font-medium transition-colors duration-200"
|
||||
class:text-green-600={isReportPDFEnabled}
|
||||
class:dark:text-green-400={isReportPDFEnabled}
|
||||
class:text-gray-500={!isReportPDFEnabled}
|
||||
class:dark:text-gray-400={!isReportPDFEnabled}
|
||||
>
|
||||
{isReportPDFEnabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
size={'large'}
|
||||
backgroundColor={isReportPDFEnabled ? 'bg-red-600' : 'bg-cta-blue'}
|
||||
disabled={isTogglingReportPDF}
|
||||
on:click={onClickReportPDFToggle}
|
||||
>
|
||||
{isReportPDFEnabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
{#if !isCompanyContext}
|
||||
<SettingsCard title="Report Template">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Default HTML template used when generating campaign PDF reports. Companies without their own
|
||||
template fall back to this.
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size={'large'} on:click={openReportTemplateModal}>Edit Template</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isReportTemplateModalVisible}
|
||||
<Modal
|
||||
bind:visible={isReportTemplateModalVisible}
|
||||
headerText="Edit Report Template"
|
||||
onClose={closeReportTemplateModal}
|
||||
>
|
||||
<FormGrid
|
||||
on:submit={onSubmitReportTemplate}
|
||||
isSubmitting={isReportTemplateSubmitting}
|
||||
modalMode="update"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Editor contentType="report" bind:value={reportTemplateContent} />
|
||||
<FormError message={reportTemplateError} />
|
||||
</div>
|
||||
<FormFooter isSubmitting={isReportTemplateSubmitting} closeModal={closeReportTemplateModal} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if isReportPDFEnableModalVisible}
|
||||
<Alert
|
||||
headline="Enable PDF Reports"
|
||||
bind:visible={isReportPDFEnableModalVisible}
|
||||
onConfirm={onConfirmEnableReportPDF}
|
||||
ok="Enable"
|
||||
>
|
||||
<div class="mt-4 text-gray-700 dark:text-gray-200 space-y-3">
|
||||
<p>
|
||||
PDF report generation requires Chromium and additional system dependencies that are not part
|
||||
of the standard installation.
|
||||
</p>
|
||||
<p>
|
||||
Before enabling, ensure the host has the required libraries and any AppArmor restrictions on
|
||||
unprivileged user namespaces have been addressed.
|
||||
</p>
|
||||
<p>
|
||||
See <a
|
||||
href="https://phishing.club/guide/settings/#pdf-reports"
|
||||
target="_blank"
|
||||
class="underline">the setup guide</a
|
||||
> for dependency installation and AppArmor configuration.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
@@ -0,0 +1,205 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { immediateResponseHandler } from '$lib/api/middleware.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { onClickCopy } from '$lib/utils/common';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Form from '$lib/components/Form.svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
|
||||
const logLevels = ['debug', 'info', 'warn', 'error'];
|
||||
const dbLogLevels = ['silent', 'info', 'warn', 'error'];
|
||||
|
||||
let loaded = false;
|
||||
let logLevel = '';
|
||||
let dbLogLevel = '';
|
||||
|
||||
let version = '';
|
||||
let updateAvailable = false;
|
||||
let isCheckingUpdate = false;
|
||||
|
||||
let isWipingBrowserCache = false;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshLogLevel();
|
||||
await refreshVersion();
|
||||
await refreshUpdateCached();
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshLogLevel() {
|
||||
try {
|
||||
const res = immediateResponseHandler(await api.log.getLevel());
|
||||
if (res.success) {
|
||||
logLevel = res.data.level;
|
||||
dbLogLevel = res.data.dbLevel;
|
||||
} else {
|
||||
console.error(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function setLogLevel() {
|
||||
try {
|
||||
const res = await api.log.setLevel(logLevel, dbLogLevel);
|
||||
if (!res.success) {
|
||||
console.error(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVersion() {
|
||||
try {
|
||||
const res = await api.version.get();
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
version = res.data;
|
||||
} catch (e) {
|
||||
console.error('failed to check version', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUpdateCached() {
|
||||
try {
|
||||
const res = await api.application.isUpdateAvailableCached();
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
updateAvailable = res.data.updateAvailable;
|
||||
} catch (e) {
|
||||
console.error('failed to check cached update status', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
isCheckingUpdate = true;
|
||||
try {
|
||||
const res = await api.application.isUpdateAvailable();
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
updateAvailable = res.data.updateAvailable;
|
||||
if (updateAvailable) {
|
||||
addToast('Update available!', 'Success');
|
||||
} else {
|
||||
addToast('No updates available', 'Info');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to check for updates', 'Error');
|
||||
console.error('failed to check for updates', e);
|
||||
} finally {
|
||||
isCheckingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onWipeBrowserCache = async () => {
|
||||
isWipingBrowserCache = true;
|
||||
try {
|
||||
const response = await api.reportTemplate.wipeBrowserCache();
|
||||
if (response.success) {
|
||||
addToast('Browser cache wiped', 'Success');
|
||||
} else {
|
||||
addToast(response.error || 'Failed to wipe browser cache', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to wipe browser cache', 'Error');
|
||||
} finally {
|
||||
isWipingBrowserCache = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Logging">
|
||||
<Form>
|
||||
<TextFieldSelect
|
||||
id="appLogLevel"
|
||||
required
|
||||
bind:value={logLevel}
|
||||
onSelect={setLogLevel}
|
||||
options={logLevels}>Application log level</TextFieldSelect
|
||||
>
|
||||
|
||||
<TextFieldSelect
|
||||
id="dbLogLevel"
|
||||
required
|
||||
bind:value={dbLogLevel}
|
||||
onSelect={setLogLevel}
|
||||
options={dbLogLevels}>Database log level</TextFieldSelect
|
||||
>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Browser Cache">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Chromium is downloaded and cached for PDF reports and remote browser sessions. Wipe to force a
|
||||
fresh download.
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
size={'large'}
|
||||
backgroundColor="bg-red-600"
|
||||
disabled={isWipingBrowserCache}
|
||||
on:click={onWipeBrowserCache}
|
||||
>
|
||||
{isWipingBrowserCache ? 'Wiping...' : 'Wipe Browser Cache'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="About">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Version</span>
|
||||
<button
|
||||
on:click|preventDefault={() => onClickCopy(version)}
|
||||
class="flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 py-1 px-2 rounded-md text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
<span class="font-mono">{version}</span>
|
||||
<img class="w-4 h-4" src="/icon-copy.svg" alt="copy version" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Status</span>
|
||||
{#if updateAvailable}
|
||||
<a
|
||||
href="/settings/update/"
|
||||
class="text-blue-600 dark:text-white hover:underline transition-colors duration-200"
|
||||
>Update available</a
|
||||
>
|
||||
{:else}
|
||||
<span class="text-gray-700 dark:text-gray-200 transition-colors duration-200">Up to date</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Licenses</span>
|
||||
<a
|
||||
href="/licenses.txt"
|
||||
class="text-blue-600 dark:text-white hover:underline transition-colors duration-200"
|
||||
>View licenses</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size={'large'} disabled={isCheckingUpdate} on:click={checkForUpdate}>
|
||||
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user