EagleCast
publish-github-pages / deploy (push) Has been cancelled

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
<template>
<div id="app">
<b-navbar :fixed-top="true" v-if="$root.isLoaded">
<template #brand>
<div class="logo">
<router-link :to="{ name: 'dashboard' }">
<img class="full" src="@/assets/logo.svg" alt="" />
<img class="favicon" src="@/assets/favicon.png" alt="" />
</router-link>
</div>
</template>
<template #end>
<navigation v-if="isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-item tag="a" href="#" @click.prevent="emitPageRefresh" data-cy="btn-refresh"
:aria-label="$t('globals.buttons.refresh')">
<b-tooltip :label="$t('globals.buttons.refresh')" type="is-dark" position="is-bottom">
<b-icon icon="refresh" /> <span class="is-hidden-tablet">{{ $t('globals.buttons.refresh') }}</span>
</b-tooltip>
</b-navbar-item>
<b-navbar-dropdown class="user" tag="div" right>
<template v-if="profile.username" #label>
<span class="user-avatar">
<img v-if="profile.avatar" :src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
</span>
</template>
<b-navbar-item class="user-name" tag="router-link" to="/user/profile">
<strong>{{ profile.username }}</strong>
<div class="is-size-7">{{ profile.name }}</div>
</b-navbar-item>
<b-navbar-item href="#">
<router-link to="/user/profile">
<b-icon icon="account-outline" /> {{ $t('users.profile') }}
</router-link>
</b-navbar-item>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="logout-variant" /> {{ $t('users.logout') }}</a>
</b-navbar-item>
</b-navbar-dropdown>
</template>
</b-navbar>
<div class="wrapper" v-if="$root.isLoaded">
<section class="sidebar">
<b-sidebar position="static" mobile="hide" :fullheight="true" :open="true" :can-cancel="false">
<div>
<b-menu :accordion="false">
<navigation v-if="!isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" />
</b-menu>
</div>
</b-sidebar>
</section>
<!-- sidebar-->
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="isGlobalNotices">
<div v-if="serverConfig.needs_restart" class="notification is-danger">
{{ $t('settings.needsRestart') }}
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm($t('settings.confirmRestart'), reloadApp)">
{{ $t('settings.restart') }}
</b-button>
</div>
<div v-if="serverConfig.has_legacy_user" class="notification is-danger">
<b-icon icon="warning-empty" />
Remove the <code>admin_username</code> and <code>admin_password</code> fields from the TOML
configuration file or environment variables. If you are using APIs, create and use new API credentials
before removing them. Visit
<router-link :to="{ name: 'users' }">
Admin -> Settings -> Users
</router-link> dashboard.
</div>
</div>
<router-view :key="$route.fullPath" />
</div>
</div>
<b-loading v-if="!$root.isLoaded" active />
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import { uris } from './constants';
import Navigation from './components/Navigation.vue';
export default Vue.extend({
name: 'App',
components: {
Navigation,
},
data() {
return {
activeItem: {},
activeGroup: {},
windowWidth: window.innerWidth,
};
},
watch: {
$route(to) {
// Set the current route name to true for active+expanded keys in the
// menu to pick up.
this.activeItem = { [to.name]: true };
if (to.meta.group) {
this.activeGroup = { [to.meta.group]: true };
} else {
// Reset activeGroup to collapse menu items on navigating
// to non group items from sidebar
this.activeGroup = {};
}
},
},
methods: {
toggleGroup(group, state) {
this.activeGroup = state ? { [group]: true } : {};
},
emitPageRefresh() {
this.$root.$emit('page.refresh');
},
reloadApp() {
this.$api.reloadApp().then(() => {
this.$utils.toast('Reloading app ...');
// Poll until there's a 200 response, waiting for the app
// to restart and come back up.
const pollId = setInterval(() => {
this.$api.getHealth().then(() => {
clearInterval(pollId);
document.location.reload();
});
}, 500);
});
},
doLogout() {
this.$api.logout().then(() => {
document.location.href = uris.root;
});
},
listenEvents() {
const reMatchLog = /(.+?)\.go:\d+:(.+?)$/im;
const evtSource = new EventSource(uris.errorEvents, { withCredentials: true });
let numEv = 0;
evtSource.onmessage = (e) => {
if (numEv > 50) {
return;
}
numEv += 1;
const d = JSON.parse(e.data);
if (d && d.type === 'error') {
const msg = reMatchLog.exec(d.message.trim());
this.$utils.toast(msg[2], 'is-danger', null, true);
}
};
},
},
computed: {
...mapState(['serverConfig', 'profile']),
isGlobalNotices() {
return (this.serverConfig.needs_restart
|| this.serverConfig.has_legacy_user);
},
version() {
return import.meta.env.VUE_APP_VERSION;
},
isMobile() {
return this.windowWidth <= 768;
},
},
mounted() {
// Lists is required across different views. On app load, fetch the lists
// and have them in the store.
this.$api.getLists({ minimal: true, per_page: 'all', status: 'active' });
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth;
});
this.listenEvents();
},
});
</script>
<style lang="scss">
@import "assets/style.scss";
@import "assets/icons/fontello.css";
</style>
+576
View File
@@ -0,0 +1,576 @@
import { ToastProgrammatic as Toast } from 'buefy';
import axios from 'axios';
import qs from 'qs';
import store from '../store';
import { models } from '../constants';
import Utils from '../utils';
const http = axios.create({
baseURL: import.meta.env.VUE_APP_ROOT_URL || '/',
withCredentials: false,
responseType: 'json',
// Override the default serializer to switch params from becoming []id=a&[]id=b ...
// in GET and DELETE requests to id=a&id=b.
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
});
const utils = new Utils();
// Intercept requests to set the 'loading' state of a model.
http.interceptors.request.use((config) => {
if ('loading' in config) {
store.commit('setLoading', { model: config.loading, status: true });
}
return config;
}, (error) => Promise.reject(error));
// Intercept responses to set them to store.
http.interceptors.response.use((resp) => {
// Clear the loading state for a model.
if ('loading' in resp.config) {
store.commit('setLoading', { model: resp.config.loading, status: false });
}
let data = {};
if (typeof resp.data.data === 'object') {
if (resp.data.data.constructor === Object) {
data = { ...resp.data.data };
} else {
data = [...resp.data.data];
}
// Transform keys to camelCase.
switch (typeof resp.config.camelCase) {
case 'function':
data = utils.camelKeys(data, resp.config.camelCase);
break;
case 'boolean':
if (resp.config.camelCase) {
data = utils.camelKeys(data);
}
break;
default:
data = utils.camelKeys(data);
break;
}
} else {
data = resp.data.data;
}
// Store the API response for a model.
if ('store' in resp.config) {
store.commit('setModelResponse', { model: resp.config.store, data });
}
return data;
}, (err) => {
// Clear the loading state for a model.
if ('loading' in err.config) {
store.commit('setLoading', { model: err.config.loading, status: false });
}
let msg = '';
if (err.response && err.response.data && err.response.data.message) {
msg = err.response.data.message;
} else {
msg = err.toString();
}
if (!err.config.disableToast) {
Toast.open({
message: msg,
type: 'is-danger',
queue: false,
position: 'is-top',
pauseOnHover: true,
});
}
return Promise.reject(err);
});
// API calls accept the following config keys.
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
// Health check endpoint that does not throw a toast.
export const getHealth = () => http.get(
'/api/health',
{ disableToast: true },
);
export const reloadApp = () => http.post('/api/admin/reload');
// Dashboard
export const getDashboardCounts = () => http.get(
'/api/dashboard/counts',
{ loading: models.dashboard },
);
export const getDashboardCharts = () => http.get(
'/api/dashboard/charts',
{ loading: models.dashboard },
);
// Lists.
export const getLists = (params) => http.get(
'/api/lists',
{
params: (!params ? { per_page: 'all' } : params),
loading: models.lists,
store: models.lists,
},
);
export const queryLists = (params) => http.get(
'/api/lists',
{
params: (!params ? { per_page: 'all' } : params),
loading: models.listsFull,
},
);
export const getList = async (id) => http.get(
`/api/lists/${id}`,
{ loading: models.list },
);
export const createList = (data) => http.post(
'/api/lists',
data,
{ loading: models.lists },
);
export const updateList = (data) => http.put(
`/api/lists/${data.id}`,
data,
{ loading: models.lists },
);
export const deleteList = (id) => http.delete(
`/api/lists/${id}`,
{ loading: models.lists },
);
export const deleteLists = (params) => http.delete(
'/api/lists',
{ params, loading: models.lists },
);
// Subscribers.
export const getSubscribers = async (params) => http.get(
'/api/subscribers',
{
params,
loading: models.subscribers,
store: models.subscribers,
camelCase: (keyPath) => !keyPath.startsWith('.results.*.attribs'),
},
);
export const getSubscriber = async (id) => http.get(
`/api/subscribers/${id}`,
{ loading: models.subscribers },
);
export const getSubscriberActivity = async (id) => http.get(
`/api/subscribers/${id}/activity`,
{ loading: models.subscribers },
);
export const getSubscriberBounces = async (id) => http.get(
`/api/subscribers/${id}/bounces`,
{ loading: models.bounces },
);
export const deleteSubscriberBounces = async (id) => http.delete(
`/api/subscribers/${id}/bounces`,
{ loading: models.bounces },
);
export const deleteBounce = async (id) => http.delete(
`/api/bounces/${id}`,
{ loading: models.bounces },
);
export const deleteBounces = async (params) => http.delete(
'/api/bounces',
{ params, loading: models.bounces },
);
export const blocklistBouncedSubscribers = async () => http.put(
'/api/bounces/blocklist',
{ loading: models.bounces },
);
export const createSubscriber = (data) => http.post(
'/api/subscribers',
data,
{ loading: models.subscribers },
);
export const updateSubscriber = (data) => http.put(
`/api/subscribers/${data.id}`,
data,
{ loading: models.subscribers },
);
export const sendSubscriberOptin = (id) => http.post(
`/api/subscribers/${id}/optin`,
{},
{ loading: models.subscribers },
);
export const deleteSubscriber = (id) => http.delete(
`/api/subscribers/${id}`,
{ loading: models.subscribers },
);
export const addSubscribersToLists = (data) => http.put(
'/api/subscribers/lists',
data,
{ loading: models.subscribers },
);
export const addSubscribersToListsByQuery = (data) => http.put(
'/api/subscribers/query/lists',
data,
{ loading: models.subscribers },
);
export const blocklistSubscribers = (data) => http.put(
'/api/subscribers/blocklist',
data,
{ loading: models.subscribers },
);
export const blocklistSubscribersByQuery = (data) => http.put(
'/api/subscribers/query/blocklist',
data,
{ loading: models.subscribers },
);
export const deleteSubscribers = (params) => http.delete(
'/api/subscribers',
{ params, loading: models.subscribers },
);
export const deleteSubscribersByQuery = (data) => http.post(
'/api/subscribers/query/delete',
data,
{ loading: models.subscribers },
);
// Subscriber import.
export const importSubscribers = (data) => http.post('/api/import/subscribers', data);
export const getImportStatus = () => http.get('/api/import/subscribers');
export const getImportLogs = async () => http.get(
'/api/import/subscribers/logs',
{ camelCase: false },
);
export const stopImport = () => http.delete('/api/import/subscribers');
// Bounces.
export const getBounces = async (params) => http.get(
'/api/bounces',
{ params, loading: models.bounces },
);
// Campaigns.
export const getCampaigns = async (params) => http.get('/api/campaigns', {
params,
loading: models.campaigns,
store: models.campaigns,
camelCase: (keyPath) => !keyPath.startsWith('.results.*.headers'),
});
export const getCampaign = async (id) => http.get(`/api/campaigns/${id}`, {
loading: models.campaigns,
camelCase: (keyPath) => !keyPath.startsWith('.headers'),
});
export const getCampaignStats = async () => http.get('/api/campaigns/running/stats', {});
export const createCampaign = async (data) => http.post(
'/api/campaigns',
data,
{ loading: models.campaigns },
);
export const getCampaignViewCounts = async (params) => http.get(
'/api/campaigns/analytics/views',
{ params, loading: models.campaigns },
);
export const getCampaignClickCounts = async (params) => http.get(
'/api/campaigns/analytics/clicks',
{ params, loading: models.campaigns },
);
export const getCampaignBounceCounts = async (params) => http.get(
'/api/campaigns/analytics/bounces',
{ params, loading: models.campaigns },
);
export const getCampaignLinkCounts = async (params) => http.get(
'/api/campaigns/analytics/links',
{ params, loading: models.campaigns },
);
export const convertCampaignContent = async (data) => http.post(
`/api/campaigns/${data.id}/content`,
data,
{ loading: models.campaigns },
);
export const testCampaign = async (data) => http.post(
`/api/campaigns/${data.id}/test`,
data,
{ loading: models.campaigns },
);
export const updateCampaign = async (id, data) => http.put(
`/api/campaigns/${id}`,
data,
{ loading: models.campaigns },
);
export const changeCampaignStatus = async (id, status) => http.put(
`/api/campaigns/${id}/status`,
{ status },
{ loading: models.campaigns },
);
export const updateCampaignArchive = async (id, data) => http.put(
`/api/campaigns/${id}/archive`,
data,
{ loading: models.campaigns },
);
export const deleteCampaign = async (id) => http.delete(
`/api/campaigns/${id}`,
{ loading: models.campaigns },
);
export const deleteCampaigns = (params) => http.delete(
'/api/campaigns',
{ params, loading: models.campaigns },
);
// Media.
export const getMedia = async (params) => http.get(
'/api/media',
{ params, loading: models.media, store: models.media },
);
export const uploadMedia = (data) => http.post(
'/api/media',
data,
{ loading: models.media },
);
export const deleteMedia = (id) => http.delete(
`/api/media/${id}`,
{ loading: models.media },
);
// Templates.
export const createTemplate = async (data) => http.post(
'/api/templates',
data,
{ loading: models.templates },
);
export const getTemplates = async () => http.get(
'/api/templates',
{ loading: models.templates, store: models.templates },
);
export const getTemplate = async (id) => http.get(
`/api/templates/${id}`,
{ loading: models.templates },
);
export const updateTemplate = async (data) => http.put(
`/api/templates/${data.id}`,
data,
{ loading: models.templates },
);
export const makeTemplateDefault = async (id) => http.put(
`/api/templates/${id}/default`,
{},
{ loading: models.templates },
);
export const deleteTemplate = async (id) => http.delete(
`/api/templates/${id}`,
{ loading: models.templates },
);
// Settings.
export const getServerConfig = async () => http.get(
'/api/config',
{ loading: models.serverConfig, store: models.serverConfig, camelCase: false },
);
export const getSettings = async () => http.get(
'/api/settings',
{ loading: models.settings, store: models.settings, camelCase: false },
);
export const updateSettings = async (data) => http.put(
'/api/settings',
data,
{ loading: models.settings },
);
export const updateSettingsByKey = async (key, data) => http.put(
`/api/settings/${key}`,
data,
{ loading: models.settings },
);
export const testSMTP = async (data) => http.post(
'/api/settings/smtp/test',
data,
{ loading: models.settings, disableToast: true },
);
export const getLogs = async () => http.get(
'/api/logs',
{ loading: models.logs, camelCase: false },
);
export const getLang = async (lang) => http.get(
`/api/lang/${lang}`,
{ loading: models.lang, camelCase: false },
);
export const logout = async () => http.post('/api/logout');
export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete(
`/api/maintenance/analytics/${typ}`,
{ loading: models.maintenance, params: { before_date: beforeDate } },
);
export const deleteGCSubscribers = async (typ) => http.delete(
`/api/maintenance/subscribers/${typ}`,
{ loading: models.maintenance },
);
export const deleteGCSubscriptions = async (beforeDate) => http.delete(
'/api/maintenance/subscriptions/unconfirmed',
{ loading: models.maintenance, params: { before_date: beforeDate } },
);
// Users.
export const getUsers = () => http.get(
'/api/users',
{
loading: models.users,
store: models.users,
},
);
export const queryUsers = () => http.get(
'/api/users',
{
loading: models.users,
store: models.users,
},
);
export const getUser = async (id) => http.get(
`/api/users/${id}`,
{ loading: models.users },
);
export const createUser = (data) => http.post(
'/api/users',
data,
{ loading: models.users },
);
export const updateUser = (data) => http.put(
`/api/users/${data.id}`,
data,
{ loading: models.users },
);
export const deleteUser = (id) => http.delete(
`/api/users/${id}`,
{ loading: models.users },
);
export const getUserProfile = () => http.get(
'/api/profile',
{ loading: models.users, store: models.profile },
);
export const updateUserProfile = (data) => http.put(
'/api/profile',
data,
{ loading: models.users, store: models.profile },
);
export const getUserRoles = async () => http.get(
'/api/roles/users',
{ loading: models.userRoles, store: models.userRoles },
);
export const getListRoles = async () => http.get(
'/api/roles/lists',
{ loading: models.listRoles, store: models.listRoles },
);
export const createUserRole = (data) => http.post(
'/api/roles/users',
data,
{ loading: models.userRoles },
);
export const createListRole = (data) => http.post(
'/api/roles/lists',
data,
{ loading: models.listRoles },
);
export const updateUserRole = (data) => http.put(
`/api/roles/users/${data.id}`,
data,
{ loading: models.userRoles },
);
export const updateListRole = (data) => http.put(
`/api/roles/lists/${data.id}`,
data,
{ loading: models.userRoles },
);
export const deleteRole = (id) => http.delete(
`/api/roles/${id}`,
{ loading: models.userRoles },
);
// TOTP 2FA APIs
export const getTOTPQR = (id) => http.get(
`/api/users/${id}/twofa/totp`,
{ camelCase: true },
);
export const enableTOTP = (id, data) => http.put(
`/api/users/${id}/twofa`,
data,
);
export const disableTOTP = (id, data) => http.delete(
`/api/users/${id}/twofa`,
{ data },
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.
+123
View File
@@ -0,0 +1,123 @@
@font-face {
font-family: 'fontello';
src: url('fontello.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
[class^="mdi-"]:before, [class*=" mdi-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
[class^="mdi-"]:before, [class*=" mdi-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
.mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */
.mdi-account-multiple:before { content: '\e803'; } /* '' */
.mdi-file-upload-outline:before { content: '\e804'; } /* '' */
.mdi-rocket-launch-outline:before { content: '\e805'; } /* '' */
.mdi-plus:before { content: '\e806'; } /* '' */
.mdi-image-outline:before { content: '\e807'; } /* '' */
.mdi-file-image-outline:before { content: '\e808'; } /* '' */
.mdi-cog-outline:before { content: '\e809'; } /* '' */
.mdi-tag-outline:before { content: '\e80a'; } /* '' */
.mdi-calendar-clock:before { content: '\e80b'; } /* '' */
.mdi-email-outline:before { content: '\e80c'; } /* '' */
.mdi-text:before { content: '\e80d'; } /* '' */
.mdi-alarm:before { content: '\e80e'; } /* '' */
.mdi-pause-circle-outline:before { content: '\e80f'; } /* '' */
.mdi-file-find-outline:before { content: '\e810'; } /* '' */
.mdi-clock-start:before { content: '\e811'; } /* '' */
.mdi-file-multiple-outline:before { content: '\e812'; } /* '' */
.mdi-trash-can-outline:before { content: '\e813'; } /* '' */
.mdi-pencil-outline:before { content: '\e814'; } /* '' */
.mdi-arrow-top-right:before { content: '\e815'; } /* '' */
.mdi-link-variant:before { content: '\e816'; } /* '' */
.mdi-cloud-download-outline:before { content: '\e817'; } /* '' */
.mdi-account-search-outline:before { content: '\e818'; } /* '' */
.mdi-check-circle-outline:before { content: '\e819'; } /* '' */
.mdi-account-check-outline:before { content: '\e81a'; } /* '' */
.mdi-account-off-outline:before { content: '\e81b'; } /* '' */
.mdi-chevron-right:before { content: '\e81c'; } /* '' */
.mdi-chevron-left:before { content: '\e81d'; } /* '' */
.mdi-content-save-outline:before { content: '\e81e'; } /* '' */
.mdi-minus:before { content: '\e81f'; } /* '' */
.mdi-arrow-up:before { content: '\e820'; } /* '' */
.mdi-arrow-down:before { content: '\e821'; } /* '' */
.mdi-cancel:before { content: '\e822'; } /* '' */
.mdi-magnify:before { content: '\e823'; } /* '' */
.mdi-chart-bar:before { content: '\e824'; } /* '' */
.mdi-email-bounce:before { content: '\e825'; } /* '' */
.mdi-speedometer:before { content: '\e826'; } /* '' */
.mdi-warning-empty:before { content: '\e827'; } /* '' */
.mdi-account-outline:before { content: '󰀓'; } /* '\f0013' */
.mdi-code:before { content: '󰅩'; } /* '\f0169' */
.mdi-refresh:before { content: '󰑐'; } /* '\f0450' */
.mdi-logout-variant:before { content: '󰗽'; } /* '\f05fd' */
.mdi-wrench-outline:before { content: '󰯠'; } /* '\f0be0' */
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="170" height="32" viewBox="0 0 170 32">
<title>EagleCast</title>
<g>
<circle cx="16" cy="16" r="15" fill="#1b3a5b"/>
<path d="M9.2 13.2 C9.2 9.2 12.6 6.6 16.4 6.6 C20.2 6.6 23.3 8.9 24.4 11.9 L30.4 14.2 L24.7 15.9 C24.8 16.5 24.3 17.1 23.4 17.1 L21.7 17.1 C22.5 21.6 20.4 25.1 16.7 26.1 C14.7 26.6 13.1 25.7 12.3 23.9 C10.5 21.0 9.5 17.3 9.2 13.2 Z" fill="#ffffff"/>
<path d="M24.4 11.9 L30.4 14.2 L24.7 15.9 C25.0 14.5 24.8 13.1 24.4 11.9 Z" fill="#eaa221"/>
<circle cx="19.8" cy="11.9" r="1.5" fill="#1b3a5b"/>
</g>
<text x="38" y="22.5" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="18" font-weight="700" fill="#1b3a5b">Eagle<tspan fill="#eaa221">Cast</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 769 B

File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
<template>
<section class="bar-chart">
<canvas class="bar-chart-canvas" />
</section>
</template>
<script>
import Chart from 'chart.js/auto';
const DEFAULT = {
type: 'bar',
data: {},
options: {
responsive: true,
indexAxis: 'y',
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#fff',
borderColor: '#ddd',
borderWidth: 1,
titleColor: '#666',
bodyColor: '#666',
bodyFont: {
size: 15,
},
bodySpacing: 10,
padding: 10,
},
},
},
};
export default {
name: 'BarChart',
props: {
data: { type: Object, default: () => { } },
},
mounted() {
const ctx = this.$el.querySelector('.bar-chart-canvas');
this.chart = new Chart(ctx, { ...DEFAULT, data: this.$props.data });
},
};
</script>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div>
<b-modal scroll="keep" @close="close" :aria-modal="true" :active="isVisible">
<div>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4>{{ title }}</h4>
</header>
</div>
<section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false" />
<form v-if="isPost" method="post" :action="previewURL" target="iframe" ref="form">
<input v-if="templateId" type="hidden" name="template_id" :value="templateId" />
<input v-if="contentType" type="hidden" name="content_type" :value="contentType" />
<input v-if="templateType" type="hidden" name="template_type" :value="templateType" />
<input v-if="archiveMeta" type="hidden" name="archive_meta" :value="archiveMeta" />
<input v-if="body" type="hidden" name="body" :value="body" />
</form>
<iframe id="iframe" name="iframe" ref="iframe" :title="title" :src="isPost ? 'about:blank' : previewURL"
@load="onLoaded" sandbox="allow-scripts" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="close">
{{ $t('globals.buttons.close') }}
</b-button>
</footer>
</div>
</b-modal>
</div>
</template>
<script>
import { uris } from '../constants';
export default {
name: 'CampaignPreview',
props: {
isPost: { type: Boolean, default: false },
// Template or campaign ID.
id: { type: Number, default: 0 },
title: { type: String, default: '' },
// campaign | template.
type: { type: String, default: '' },
// campaign | tx.
templateType: { type: String, default: '' },
archiveMeta: { type: String, default: null },
body: { type: String, default: '' },
contentType: { type: String, default: '' },
templateId: { type: [Number, null], default: null },
isArchive: { type: Boolean, default: false },
},
data() {
return {
isVisible: true,
isLoading: true,
formSubmitted: false,
};
},
methods: {
close() {
this.$emit('close');
this.isVisible = false;
},
// On iframe load, kill the spinner.
onLoaded() {
if (!this.isPost) {
this.isLoading = false;
return;
}
if (this.formSubmitted) {
this.isLoading = false;
}
},
},
computed: {
previewURL() {
let uri = 'about:blank';
if (this.type === 'campaign') {
uri = this.isArchive ? uris.previewCampaignArchive : uris.previewCampaign;
} else if (this.type === 'template') {
if (this.id) {
uri = uris.previewTemplate;
} else {
uri = uris.previewRawTemplate;
}
}
return uri.replace(':id', this.id);
},
},
mounted() {
if (this.isPost) {
setTimeout(() => {
this.$refs.form.submit();
this.formSubmitted = true;
}, 100);
}
},
};
</script>
+164
View File
@@ -0,0 +1,164 @@
<template>
<section class="chart">
<canvas class="chart-canvas" />
</section>
</template>
<script>
import Chart from 'chart.js/auto';
const DEFAULT_DONUT = {
type: 'doughnut',
data: {},
options: {
responsive: true,
cutout: '70%',
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#fff',
borderColor: '#ddd',
borderWidth: 1,
titleColor: '#666',
bodyColor: '#666',
bodyFont: {
size: 15,
},
bodySpacing: 10,
padding: 10,
callbacks: {
label: (item) => {
const data = item.chart.data.datasets[item.datasetIndex];
const total = data.data.reduce((acc, val) => acc + val, 0);
const val = data.data[item.dataIndex];
const percentage = ((val / total) * 100).toFixed(2);
return `${val} (${percentage}%)`;
},
},
},
},
},
};
const DEFAULT_LINE = {
type: 'line',
data: {},
options: {
responsive: true,
lineTension: 0.5,
maintainAspectRatio: false,
interaction: {
intersect: false,
axis: 'index',
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#fff',
borderColor: '#ddd',
borderWidth: 1,
bodyColor: '#666',
displayColors: true,
bodyFont: {
size: 15,
},
bodySpacing: 10,
padding: 10,
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
grid: {
display: false,
},
ticks: {
precision: 0,
},
},
},
},
};
const DEFAULT_BAR = {
type: 'bar',
data: {},
options: {
responsive: true,
indexAxis: 'y',
barThickness: 40,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#fff',
borderColor: '#ddd',
borderWidth: 1,
titleColor: '#666',
bodyColor: '#666',
bodyFont: {
size: 15,
},
bodySpacing: 10,
padding: 10,
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
grid: {
display: false,
},
},
},
},
};
export default {
name: 'Chart',
props: {
data: { type: Object, default: () => { } },
type: { type: String, default: 'line' },
onClick: { type: Function, default: () => { } },
},
mounted() {
const ctx = this.$el.querySelector('.chart-canvas');
let def = {};
switch (this.$props.type) {
case 'donut':
def = DEFAULT_DONUT;
break;
case 'bar':
def = DEFAULT_BAR;
break;
default:
def = DEFAULT_LINE;
break;
}
const conf = { ...def, data: this.$props.data };
if (this.$props.onClick) {
conf.options.onClick = this.$props.onClick;
}
this.chart = new Chart(ctx, conf);
},
};
</script>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div ref="editor" class="code-editor" />
</template>
<script>
import { EditorState } from '@codemirror/state';
import {
EditorView, keymap, highlightActiveLine, lineNumbers, highlightActiveLineGutter,
} from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { javascript } from '@codemirror/lang-javascript';
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import {
defaultKeymap, history, historyKeymap, indentWithTab,
} from '@codemirror/commands';
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { vsCodeLight } from './editor-theme';
export default {
props: {
value: { type: String, default: '' },
lang: { type: String, default: 'html' },
disabled: Boolean,
},
data() {
return {
data: '',
editor: null,
internalUpdate: false,
};
},
methods: {
},
mounted() {
const onUpdate = EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.internalUpdate = true;
this.$emit('input', update.state.doc.toString());
}
});
// Set the chosen language.
let langs = [];
switch (this.lang) {
case 'html':
langs = [html()];
break;
case 'css':
langs = [css()];
break;
case 'javascript':
langs = [javascript()];
break;
case 'markdown':
langs = [markdown()];
break;
default:
langs = [html()];
}
// Prepare the full config.
const stateCfg = EditorState.create({
// Initial value.
doc: this.value,
extensions: [
EditorView.baseTheme({}),
...langs,
history(),
highlightActiveLine(),
bracketMatching(),
highlightSelectionMatches(),
lineNumbers(),
highlightActiveLineGutter(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
// Readonly?
EditorState.readOnly.of(this.disabled),
EditorView.editable.of(!this.disabled),
// Syntax highlighting and theme.
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
vsCodeLight,
search({
top: true, // Places the search panel at the top of the editor
}),
// On content change.
onUpdate,
],
});
// Create the editor.
this.editor = new EditorView({
state: stateCfg,
parent: this.$refs.editor,
});
this.$nextTick(() => {
window.setTimeout(() => {
this.editor.focus();
}, 100);
});
},
beforeDestroy() {
if (this.editor) {
this.editor.destroy();
}
},
watch: {
value(val) {
if (!this.internalUpdate) {
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: val },
});
this.internalUpdate = false;
}
},
},
};
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<a href="#" class="copy-text" ref="text" @click.prevent="onClick">
<template v-if="!hideText">{{ $props.text }}</template>
<b-icon icon="file-multiple-outline" size="is-small" />
</a>
</template>
<script>
export default {
name: 'CopyText',
props: {
text: { type: String, default: '' },
hideText: { type: Boolean, default: false },
},
methods: {
onClick(e) {
e.preventDefault();
e.stopPropagation();
const input = document.createElement('input');
input.setAttribute('type', 'text');
input.style.opacity = '0';
input.value = this.$props.text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
this.$utils.toast(this.$t('globals.messages.copied'));
},
},
};
</script>
+397
View File
@@ -0,0 +1,397 @@
<template>
<!-- Two-way Data-Binding -->
<section class="editor">
<div class="columns">
<div class="column is-three-quarters is-inline-flex">
<b-field :label="$t('campaigns.format')" label-position="on-border" class="mr-4 mb-0">
<b-select v-model="contentTypeSel" :disabled="disabled" name="content_type">
<option v-for="(name, f) in contentTypes" :key="f" name="format" :value="f" :data-cy="`check-${f}`">
{{ name }}
</option>
</b-select>
</b-field>
<b-field v-if="self.contentType !== 'visual'" :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$t('globals.terms.none')" v-model="templateId" name="template" :disabled="disabled">
<template v-for="t in validTemplates">
<option :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
<div v-else>
<b-button v-if="!isVisualTplSelector" @click="onShowVisualTplSelector" type="is-ghost"
icon-left="file-find-outline" data-cy="btn-select-visual-tpl">
{{ $t('campaigns.importVisualTemplate') }}
</b-button>
<b-field v-else :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$t('globals.terms.none')" v-model="visualTemplateId"
@input="() => isVisualTplDisabled = false" name="template" :disabled="disabled"
class="copy-visual-template-list">
<template v-for="t in validTemplates">
<option :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
<b-button :disabled="disabled || isVisualTplDisabled || !visualTemplateId" class="ml-3"
@click="onImportVisualTpl" type="is-primary" icon-left="content-save-outline"
data-cy="btn-save-visual-tpl">
{{ $t('globals.terms.import') }}
<span class="spinner is-tiny" v-if="loading.templates">
<b-loading :is-full-page="false" active />
</span>
</b-button>
</b-field>
</div>
</div>
<div class="column is- has-text-right">
<b-button @click="onTogglePreview" type="is-primary" icon-left="file-find-outline" data-cy="btn-preview"
aria-keyshortcuts="F9">
<span class="has-kbd">{{ $t('campaigns.preview') }} <span class="kbd">F9</span></span>
</b-button>
</div>
</div>
<!-- wsywig //-->
<richtext-editor v-if="self.contentType === 'richtext'" :disabled="disabled" v-model="self.body" />
<!-- visual editor //-->
<visual-editor v-if="self.contentType === 'visual'" :source="self.bodySource" @change="onVisualEditorChange"
height="65vh" ref="visualEditor" />
<!-- raw html editor //-->
<code-editor lang="html" v-if="self.contentType === 'html'" v-model="self.body" key="editor-html" />
<!-- markdown editor //-->
<code-editor lang="markdown" v-if="self.contentType === 'markdown'" v-model="self.body" key="editor-markdown" />
<!-- plain text //-->
<b-input v-if="self.contentType === 'plain'" v-model="self.body" type="textarea" name="content" ref="plainEditor"
class="plain-editor" />
<!-- campaign preview //-->
<campaign-preview v-if="isPreviewing" is-post @close="onTogglePreview" type="campaign" :id="id" :title="title"
:content-type="self.contentType" :template-id="templateId" :body="self.body" />
</section>
</template>
<script>
import { html as beautifyHTML } from 'js-beautify';
import TurndownService from 'turndown';
import { mapState } from 'vuex';
import CampaignPreview from './CampaignPreview.vue';
import VisualEditor from './VisualEditor.vue';
import RichtextEditor from './RichtextEditor.vue';
import markdownToVisualBlock from './editor';
import CodeEditor from './CodeEditor.vue';
const turndown = new TurndownService();
export default {
components: {
CampaignPreview,
'code-editor': CodeEditor,
'visual-editor': VisualEditor,
'richtext-editor': RichtextEditor,
},
props: {
contentTypes: { type: Object, default: () => ({}) },
id: { type: Number, default: 0 },
title: { type: String, default: '' },
disabled: { type: Boolean, default: false },
templates: { type: Array, default: null },
// value is provided by the parent component.
// Throught the editor, `this.self` (a mutable clone of `value`) is used,
// instead of `this.value` directly.
value: {
type: Object,
default: () => ({
body: '',
bodySource: null,
contentType: '',
templateId: null,
}),
},
},
data() {
return {
isPreviewing: false,
isVisualTplSelector: false,
isVisualTplDisabled: false,
contentTypeSel: this.$props.value.contentType,
templateId: null,
visualTemplateId: null,
};
},
methods: {
onContentTypeChange(to, from) {
if (!this.self.body.trim()) {
this.convertContentType(to, from);
return;
}
// Ask for confirmation as pretty much all conversions are lossy.
this.$utils.confirm(
this.$t('campaigns.confirmSwitchFormat'),
() => {
this.convertContentType(to, from);
},
() => {
// Cancelled. Reset the <select> to the last value.
this.contentTypeSel = from;
},
);
},
convertContentType(to, from) {
let body = this.self.body ?? '';
let bodySource = null;
// Skip UI update (markdown => richtext, html requires a backenbd call).
let skip = false;
// If `from` is HTML content, strip out `<body>..` etc. and keep the beautified HTML.
let isHTML = false;
if (from === 'richtext' || from === 'html' || from === 'visual') {
const d = document.createElement('div');
d.innerHTML = body;
body = this.beautifyHTML(d.innerHTML.trim());
isHTML = true;
}
// HTML => Non-HTML.
if (isHTML) {
switch (to) {
case 'plain': {
const d = document.createElement('div');
d.innerHTML = body;
body = this.trimLines(d.innerText.trim(), true);
break;
}
case 'markdown': {
body = turndown.turndown(body).replace(/\n\n+/ig, '\n\n');
break;
}
case 'visual': {
const md = turndown.turndown(body).replace(/\n\n+/ig, '\n\n');
bodySource = JSON.stringify(markdownToVisualBlock(md));
break;
}
default:
// Switching between HTML formats, no need to do anything further
// as body is already beautified.
// richtext|html => visual, the contents are simply lost.
break;
}
// Markdown to HTML requires a backend call.
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
skip = true;
this.$api.convertCampaignContent({
id: 1, body, from, to,
}).then((data) => {
this.$nextTick(() => {
// Both type + body should be updated in one cycle to avoid firing
// multiple events.
this.self.contentType = to;
this.self.body = this.beautifyHTML(data.trim());
});
});
// Plain to an HTML type, change plain line breaks to HTML breaks.
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
body = body.replace(/\n/ig, '<br>\n');
} else if (to === 'visual') {
bodySource = JSON.stringify(markdownToVisualBlock(body));
}
// =======================================================================
// Reset the campaign template ID if its converted to or from visual template.
if (to === 'visual' || from === 'visual') {
this.templateId = null;
this.self.templateId = null;
}
// =======================================================================
// Apply the conversion on the editor UI.
if (!skip) {
this.$nextTick(() => {
// Both type + body should be updated in one cycle to avoid firing
// multiple events.
this.self.contentType = to;
this.self.body = body;
this.self.bodySource = bodySource;
});
}
},
onTogglePreview() {
this.isPreviewing = !this.isPreviewing;
},
onKeyboardShortcut(e) {
// On F9, toggle the preview.
if (e.key === 'F9') {
this.onTogglePreview();
e.preventDefault();
}
// On Ctrl+S, trigger save.
if (e.ctrlKey && e.key === 's') {
this.$events.$emit('campaign.update');
e.preventDefault();
}
},
onVisualEditorChange({ body, source }) {
this.self.body = body;
this.self.bodySource = source;
},
beautifyHTML(str) {
// Pad all tags with linebreaks.
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/ig, '\n$1\n'), true);
// Remove extra linebreaks.
s = s.replace(/\n+/g, '\n');
return beautifyHTML(s, {
indent_size: 4,
indent_char: ' ',
max_preserve_newlines: 2,
inline: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'b', 'strong', 'span', 'em', 'i', 'code', 'a'],
}).trim();
},
trimLines(str, removeEmptyLines) {
const out = str.split('\n');
for (let i = 0; i < out.length; i += 1) {
const line = out[i].trim();
if (removeEmptyLines) {
out[i] = line;
} else if (line === '') {
out[i] = '';
}
}
return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
},
onShowVisualTplSelector() {
this.isVisualTplSelector = true;
this.setDefaultTemplate();
},
onImportVisualTpl() {
if (!this.visualTemplateId) {
return;
}
this.$utils.confirm(
this.$t('campaigns.confirmOverwriteContent'),
() => {
// Fetch the template body from the server.
this.$api.getTemplate(this.visualTemplateId).then((data) => {
this.self.body = data.body;
this.self.bodySource = data.bodySource;
this.isVisualTplDisabled = true;
this.$refs.visualEditor.render(JSON.parse(data.bodySource));
});
},
);
},
setDefaultTemplate() {
if (this.self.contentType === 'visual') {
this.visualTemplateId = this.validTemplates[0]?.id || null;
} else {
if (this.templateId) {
return;
}
const defaultTemplate = this.validTemplates.find((t) => t.isDefault === true);
this.templateId = defaultTemplate?.id || this.validTemplates[0]?.id || null;
}
},
},
mounted() {
// Set initial content type for the selector.
this.contentTypeSel = this.value.contentType;
this.templateId = this.value.templateId;
window.addEventListener('keydown', this.onKeyboardShortcut);
this.$events.$on('campaign.preview', () => {
this.isPreviewing = true;
});
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyboardShortcut);
this.$events.$off('campaign.preview');
},
computed: {
...mapState(['serverConfig', 'loading']),
// This is a clone of the incoming `value` prop that's mutated here.
self: {
get() {
return this.value;
},
// Any change to the local copy, emit it to the parent.
set(val) {
this.$emit('input', val);
},
},
// Returns the list of valid (visual vs. normal) templates for the template dropdown.
validTemplates() {
const typ = this.self.contentType === 'visual' ? 'campaign_visual' : 'campaign';
return this.templates.filter((t) => (t.type === typ));
},
},
watch: {
validTemplates() {
// When the filtered list of validTemplates changes (visual vs. regular),
// select the appropriate 'default' in the template select list.
this.setDefaultTemplate();
},
contentTypeSel(to, from) {
// Show the conversion prompt if the value in the dropdown isn't the same
// as the current selection. This happens when eg: contentTypeSel = html -> visual happens
// in the selector, the prompt is shown, and Cancel is clicked,
// at which point, contentTypeSel = html again, which triggers this event.
if (from !== to && to !== this.self.contentType) {
this.onContentTypeChange(to, from);
}
},
templateId(to) {
if (this.self.templateId === to) {
return;
}
this.self.templateId = to;
},
},
};
</script>
@@ -0,0 +1,21 @@
<template>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
</p>
<p>{{ !label ? $t('globals.messages.emptyState') : label }}</p>
</div>
</section>
</template>
<script>
export default {
name: 'EmptyPlaceholder',
props: {
icon: { type: String, default: '' },
label: { type: String, default: '' },
},
};
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="field list-selector">
<div :class="['list-tags', ...classes]">
<b-taglist>
<b-tag v-for="l in selectedItems" :key="l.id" :class="[l.subscriptionStatus, { 'is-restricted': l.restricted }]"
:closable="!$props.disabled && !l.restricted" :data-id="l.id" @close="removeList(l.id)" class="list">
{{ l.name }}
<sup v-if="l.optin === 'double' && l.subscriptionStatus">
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
</sup>
</b-tag>
</b-taglist>
</div>
<b-field :message="message" :label="label + (selectedItems ? ` (${selectedItems.length})` : '')"
label-position="on-border">
<b-autocomplete v-model="query" :placeholder="placeholder" clearable dropdown-position="top"
:disabled="all.length === 0 || $props.disabled" :keep-first="true" :clear-on-select="true" :open-on-focus="true"
:data="filteredLists" @select="selectList" field="name" />
</b-field>
</div>
</template>
<script>
import Vue from 'vue';
export default {
name: 'ListSelector',
props: {
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
message: { type: String, default: '' },
required: Boolean,
disabled: Boolean,
classes: {
type: Array,
default: () => [],
},
selected: {
type: Array,
default: () => [],
},
all: {
type: Array,
default: () => [],
},
},
data() {
return {
query: '',
selectedItems: [],
};
},
methods: {
selectList(l) {
if (!l) {
return;
}
this.selectedItems.push(l);
this.query = '';
// Propagate the items to the parent's v-model binding.
Vue.nextTick(() => {
this.$emit('input', this.selectedItems);
});
},
removeList(id) {
this.selectedItems = this.selectedItems.filter((l) => l.id !== id);
// Propagate the items to the parent's v-model binding.
Vue.nextTick(() => {
this.$emit('input', this.selectedItems);
});
},
},
computed: {
// Return the list of unselected lists.
filteredLists() {
// Get a map of IDs of the user subscriptions. eg: {1: true, 2: true};
const subIDs = this.selectedItems.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
// Filter lists from the global lists whose IDs are not in the user's
// subscribed ist.
const q = this.query.toLowerCase();
return this.$props.all.filter(
(l) => (!(l.id in subIDs) && l.name.toLowerCase().indexOf(q) >= 0),
);
},
},
watch: {
// This is required to update the array of lists to propagate from parent
// components and "react" on the selector.
selected() {
// Deep-copy.
this.selectedItems = JSON.parse(JSON.stringify(this.selected));
},
},
mounted() {
if (this.selected) {
this.selectedItems = JSON.parse(JSON.stringify(this.selected));
}
},
};
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<section class="log-view">
<b-loading :active="loading" :is-full-page="false" />
<div class="lines" ref="lines">
<template v-for="(l, i) in lines">
<template v-if="l">
<span :set="line = splitLine(l)" :key="i" class="line">
<span class="timestamp">{{ line.timestamp }}&nbsp;</span>
<span v-if="line.file !== '*'" class="file">{{ line.file }}:&nbsp;</span>
<span class="log-message">{{ line.message }}</span>
</span>
</template>
</template>
</div>
</section>
</template>
<script>
// Regexp for splitting log lines in the following format to
// [timestamp] [file] [message].
// 2021/05/01 00:00:00:00 init.go:99: reading config: config.toml
const reFormatLine = /^([0-9\s:/]+\.[0-9]{6}) (.+?\.go:[0-9]+|\*):\s(.+)$/;
export default {
name: 'LogView',
props: {
loading: Boolean,
lines: {
type: Array,
default: () => [],
},
},
methods: {
splitLine: (l) => {
const parts = l.split(reFormatLine);
if (parts.length !== 5) {
return {
timestamp: '',
file: '',
message: l,
};
}
return {
timestamp: parts[1],
file: parts[2],
message: parts[3],
};
},
formatLine: (l) => l.replace(reFormatLine, '<span class="stamp">$1</span> '),
},
watch: {
lines() {
this.$nextTick(() => {
this.$refs.lines.scrollTop = this.$refs.lines.scrollHeight;
});
},
},
};
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<b-menu-list>
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
@update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')">
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"
icon="format-list-bulleted-square" :label="$t('menu.allLists')" />
<b-menu-item :to="{ name: 'forms' }" tag="router-link" :active="activeItem.forms" class="forms"
icon="newspaper-variant-outline" :label="$t('menu.forms')" />
</b-menu-item><!-- lists -->
<b-menu-item v-if="$can('subscribers:*')" :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
data-cy="subscribers" @update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
:label="$t('globals.terms.subscribers')">
<b-menu-item v-if="$can('subscribers:get_all', 'subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
:active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
:label="$t('menu.allSubscribers')" />
<b-menu-item v-if="$can('subscribers:import')" :to="{ name: 'import' }" tag="router-link"
:active="activeItem.import" data-cy="import" icon="file-upload-outline" :label="$t('menu.import')" />
<b-menu-item v-if="$can('bounces:get')" :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces"
data-cy="bounces" icon="email-bounce" :label="$t('globals.terms.bounces')" />
</b-menu-item><!-- subscribers -->
<b-menu-item v-if="$can('campaigns:*')" :expanded="activeGroup.campaigns" :active="activeGroup.campaigns"
data-cy="campaigns" @update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
:label="$t('globals.terms.campaigns')">
<b-menu-item v-if="$can('campaigns:get')" :to="{ name: 'campaigns' }" tag="router-link"
:active="activeItem.campaigns" data-cy="all-campaigns" icon="rocket-launch-outline"
:label="$t('menu.allCampaigns')" />
<b-menu-item v-if="$can('campaigns:manage')" :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link"
:active="activeItem.campaign" data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
<b-menu-item v-if="$can('media:*')" :to="{ name: 'media' }" tag="router-link" :active="activeItem.media"
data-cy="media" icon="image-outline" :label="$t('menu.media')" />
<b-menu-item v-if="$can('templates:get')" :to="{ name: 'templates' }" tag="router-link"
:active="activeItem.templates" data-cy="templates" icon="file-image-outline"
:label="$t('globals.terms.templates')" />
<b-menu-item v-if="$can('campaigns:get_analytics')" :to="{ name: 'campaignAnalytics' }" tag="router-link"
:active="activeItem.campaignAnalytics" data-cy="analytics" icon="chart-bar"
:label="$t('globals.terms.analytics')" />
</b-menu-item><!-- campaigns -->
<b-menu-item v-if="$can('users:*', 'roles:*')" :expanded="activeGroup.users" :active="activeGroup.users"
data-cy="users" @update:active="(state) => toggleGroup('users', state)" icon="account-multiple"
:label="$t('globals.terms.users')">
<b-menu-item v-if="$can('users:get')" :to="{ name: 'users' }" tag="router-link" :active="activeItem.users"
data-cy="users" icon="account-multiple" :label="$t('globals.terms.users')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'userRoles' }" tag="router-link" :active="activeItem.userRoles"
data-cy="userRoles" icon="newspaper-variant-outline" :label="$t('users.userRoles')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'listRoles' }" tag="router-link" :active="activeItem.listRoles"
data-cy="listRoles" icon="format-list-bulleted-square" :label="$t('users.listRoles')" />
</b-menu-item><!-- users -->
<b-menu-item v-if="$can('settings:*')" :expanded="activeGroup.settings" :active="activeGroup.settings"
data-cy="settings" @update:active="(state) => toggleGroup('settings', state)" icon="cog-outline"
:label="$t('menu.settings')">
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
:active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item v-if="$can('settings:maintain')" :to="{ name: 'maintenance' }" tag="router-link"
:active="activeItem.maintenance" data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs"
data-cy="logs" icon="format-list-bulleted-square" :label="$t('menu.logs')" />
</b-menu-item><!-- settings -->
</b-menu-list>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'Navigation',
props: {
activeItem: { type: Object, default: () => { } },
activeGroup: { type: Object, default: () => { } },
isMobile: Boolean,
},
methods: {
toggleGroup(group, state) {
this.$emit('toggleGroup', group, state);
},
doLogout() {
this.$emit('doLogout');
},
},
computed: {
...mapState(['profile']),
},
mounted() {
// A hack to close the open accordion burger menu items on click.
// Buefy does not have a way to do this.
if (this.isMobile) {
document.querySelectorAll('.navbar li a[href]').forEach((e) => {
e.onclick = () => {
document.querySelector('.navbar-burger').click();
};
});
}
},
};
</script>
+400
View File
@@ -0,0 +1,400 @@
<template>
<div class="richtext-editor" v-if="isRichtextReady">
<tiny-mce v-model="computedValue" :disabled="disabled" :init="richtextConf" />
<b-modal scroll="keep" :width="1200" :aria-modal="true" :active.sync="isRichtextSourceVisible">
<div>
<section expanded class="modal-card-body preview">
<code-editor lang="html" v-model="richTextSourceBody" key="richtext-source" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="onFormatRichtextHTML">
{{ $t('campaigns.formatHTML') }}
</b-button>
<b-button @click="() => { this.isRichtextSourceVisible = false; }">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button @click="onSaveRichTextSource" class="is-primary">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</b-modal>
<b-modal scroll="keep" :width="750" :aria-modal="true" :active.sync="isInsertHTMLVisible">
<div>
<section expanded class="modal-card-body preview">
<code-editor lang="html" v-model="insertHTMLSnippet" key="richtext-snippet" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="onFormatRichtextHTMLSnippet">
{{ $t('campaigns.formatHTML') }}
</b-button>
<b-button @click="() => { this.isInsertHTMLVisible = false; }">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button @click="onInsertHTML" class="is-primary">
{{ $t('globals.buttons.insert') }}
</b-button>
</footer>
</div>
</b-modal>
<!-- image picker -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
<div class="modal-card content" style="width: auto">
<section expanded class="modal-card-body">
<media is-modal @selected="onMediaSelect" />
</section>
</div>
</b-modal>
</div>
</template>
<script>
import { html } from 'js-beautify';
import { mapState } from 'vuex';
import TinyMce from '@tinymce/tinymce-vue';
import 'tinymce';
import 'tinymce/icons/default';
import 'tinymce/plugins/anchor';
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/colorpicker';
import 'tinymce/plugins/contextmenu';
import 'tinymce/plugins/emoticons';
import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/fullscreen';
import 'tinymce/plugins/help';
import 'tinymce/plugins/hr';
import 'tinymce/plugins/image';
import 'tinymce/plugins/imagetools';
import 'tinymce/plugins/link';
import 'tinymce/plugins/lists';
import 'tinymce/plugins/paste';
import 'tinymce/plugins/searchreplace';
import 'tinymce/plugins/table';
import 'tinymce/plugins/textcolor';
import 'tinymce/plugins/visualblocks';
import 'tinymce/plugins/visualchars';
import 'tinymce/plugins/wordcount';
import 'tinymce/skins/ui/oxide/skin.css';
import 'tinymce/themes/silver';
import { colors, uris } from '../constants';
import Media from '../views/Media.vue';
import CodeEditor from './CodeEditor.vue';
// Map of eaglecast language codes to corresponding TinyMCE language files.
const LANGS = {
cs: 'cs',
de: 'de',
es: 'es_419',
fr: 'fr_FR',
it: 'it_IT',
pl: 'pl',
pt: 'pt_PT',
'pt-BR': 'pt_BR',
ro: 'ro',
tr: 'tr',
};
const TRACK_LINK = 'trackLink';
const TRACK_SUFFIX = '@TrackLink';
const EMBED_IMAGE = 'embedImage';
export default {
components: {
Media,
'tiny-mce': TinyMce,
'code-editor': CodeEditor,
},
props: {
disabled: { type: Boolean, default: false },
value: {
type: String,
default: '',
},
},
data() {
return {
isPreviewing: false,
isMediaVisible: false,
isReady: false,
isRichtextReady: false,
isRichtextSourceVisible: false,
isInsertHTMLVisible: false,
insertHTMLSnippet: '',
richtextConf: {},
richTextSourceBody: '',
contentType: '',
};
},
methods: {
initRichtextEditor() {
const { lang } = this.serverConfig;
this.richtextConf = {
init_instance_callback: () => { this.isReady = true; },
urlconverter_callback: this.onEditorURLConvert,
setup: (editor) => {
editor.addShortcut('ctrl+s', 'Save content', () => {
this.$events.$emit('campaign.update', {});
});
editor.addShortcut('f9', 'Preview', () => {
this.$events.$emit('campaign.preview', {});
});
editor.on('init', () => {
editor.focus();
this.onEditorDialogOpen(editor);
});
// Custom HTML editor.
editor.ui.registry.addButton('html', {
icon: 'sourcecode',
tooltip: 'Source code',
onAction: this.onRichtextViewSource,
});
editor.ui.registry.addButton('insert-html', {
icon: 'code-sample',
tooltip: 'Insert HTML',
onAction: this.onOpenInsertHTML,
});
editor.on('CloseWindow', () => {
editor.selection.getNode().scrollIntoView(false);
});
},
browser_spellcheck: true,
min_height: 500,
toolbar_sticky: true,
entity_encoding: 'raw',
convert_urls: true,
relative_urls: false,
remove_script_host: false,
extended_valid_elements: 'img[*]',
plugins: [
'anchor', 'autoresize', 'autolink', 'charmap', 'emoticons', 'fullscreen',
'help', 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
'table', 'visualblocks', 'visualchars', 'wordcount',
],
toolbar: `undo redo | formatselect styleselect fontsizeselect |
bold italic underline strikethrough forecolor backcolor subscript superscript |
alignleft aligncenter alignright alignjustify |
bullist numlist table image insert-html | outdent indent | link hr removeformat |
html fullscreen help`,
fontsize_formats: '10px 11px 12px 14px 15px 16px 18px 24px 36px',
skin: false,
content_css: false,
content_style: `
body { font-family: 'Inter', sans-serif; font-size: 15px; }
img { max-width: 100%; }
img.img-float-left { float: left; margin: 0 1em 1em 0; }
img.img-float-right { float: right; margin: 0 0 1em 1em; }
a { color: ${colors.primary}; }
table, td { border-color: #ccc;}
`,
language: LANGS[lang] || null,
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
image_advtab: true,
image_class_list: [
{ title: 'None', value: '' },
{ title: 'Float left', value: 'img-float-left' },
{ title: 'Float right', value: 'img-float-right' },
],
file_picker_types: 'image',
file_picker_callback: (callback) => {
this.isMediaVisible = true;
this.imageCallack = callback;
},
};
this.isRichtextReady = true;
},
onEditorURLConvert(url) {
return url;
},
onRichtextViewSource() {
this.richTextSourceBody = this.computedValue;
this.isRichtextSourceVisible = true;
},
onOpenInsertHTML() {
this.isInsertHTMLVisible = true;
},
onInsertHTML() {
this.isInsertHTMLVisible = false;
window.tinymce.editors[0].execCommand('mceInsertContent', false, this.insertHTMLSnippet);
this.insertHTMLSnippet = '';
},
onFormatRichtextHTML() {
this.richTextSourceBody = this.beautifyHTML(this.richTextSourceBody);
},
onFormatRichtextHTMLSnippet() {
this.insertHTMLSnippet = this.beautifyHTML(this.insertHTMLSnippet);
},
onSaveRichTextSource() {
this.computedValue = this.richTextSourceBody;
window.tinymce.editors[0].setContent(this.computedValue);
this.richTextSourceBody = '';
this.isRichtextSourceVisible = false;
},
onEditorDialogOpen(editor) {
const ed = editor;
const oldEd = ed.windowManager.open;
ed.windowManager.open = (t, r) => {
const data = t.initialData || {};
const isLink = data.url && 'anchor' in data;
const isImage = data.src && !isLink;
if (!isLink && !isImage) {
return oldEd.call(ed.windowManager, t, r);
}
const { onSubmit } = t;
const checkbox = isLink ? { type: 'checkbox', name: TRACK_LINK, label: 'Track link?' } : { type: 'checkbox', name: EMBED_IMAGE, label: this.$t('media.embed') };
const spec = { ...t, body: this.withDialogCheckbox(t.body, checkbox) };
if (isLink) {
const cleanURL = (data.url.value || '').replace(/@TrackLink$/, '');
const checked = data.url.value !== cleanURL
|| (!cleanURL && JSON.parse(localStorage.getItem(TRACK_LINK) || 'false'));
spec.initialData = { ...data, [TRACK_LINK]: checked, url: { ...data.url, value: cleanURL } };
spec.onSubmit = (api) => {
const d = api.getData();
const shouldTrack = Boolean(d[TRACK_LINK]);
const url = (d.url.value || '').replace(/@TrackLink$/, '');
localStorage.setItem(TRACK_LINK, JSON.stringify(shouldTrack));
if (shouldTrack && /^https?:\/\//i.test(url)) {
api.setData({ url: { ...d.url, value: `${url}${TRACK_SUFFIX}` } });
}
onSubmit(api);
};
} else {
const img = this.getSelectedImage(ed);
spec.initialData = { ...data, [EMBED_IMAGE]: Boolean(img && img.hasAttribute('data-embed')) };
spec.onSubmit = (api) => {
const d = api.getData();
const shouldEmbed = d[EMBED_IMAGE] === true || d[EMBED_IMAGE] === 'true';
onSubmit(api);
// Apply 'embed' attr.
const node = (img && ed.getBody().contains(img)) ? img : this.getSelectedImage(ed);
if (!node) {
return;
}
if (shouldEmbed) {
ed.dom.setAttrib(node, 'data-embed', 'true');
} else {
node.removeAttribute('data-embed');
}
ed.fire('change');
ed.save();
this.computedValue = ed.getContent();
};
}
return oldEd.call(ed.windowManager, spec, r);
};
},
withDialogCheckbox(body, checkbox) {
if (body.type === 'tabpanel') {
return {
...body,
tabs: body.tabs.map((tab) => (
tab.name === 'general' || tab.title === 'General'
? { ...tab, items: [...tab.items, checkbox] }
: tab
)),
};
}
return { ...body, items: [...body.items, checkbox] };
},
getSelectedImage(editor) {
const node = editor.selection.getNode();
if (!node) {
return null;
}
if (node.nodeName === 'IMG') {
return node;
}
const figure = editor.dom.getParent(node, 'figure.image');
return figure ? figure.querySelector('img') : null;
},
onMediaSelect(media) {
this.imageCallack(media.url);
},
beautifyHTML(str) {
// Pad all tags with linebreaks.
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/gi, '\n$1\n'), true);
// Remove extra linebreaks.
s = s.replace(/\n+/g, '\n');
try {
s = html(s).trim();
} catch (error) {
// eslint-disable-next-line no-console
console.log('error formatting HTML', error);
}
return s;
},
trimLines(str, removeEmptyLines) {
const out = str.split('\n');
for (let i = 0; i < out.length; i += 1) {
const line = out[i].trim();
if (removeEmptyLines) {
out[i] = line;
} else if (line === '') {
out[i] = '';
}
}
return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
},
},
mounted() {
this.initRichtextEditor();
},
computed: {
...mapState(['serverConfig']),
computedValue: {
get() {
return this.value;
},
set(newValue) {
this.$emit('input', newValue);
},
},
},
};
</script>
@@ -0,0 +1,162 @@
<template>
<div class="subscriber-activity">
<div v-if="isLoading" class="has-text-centered">
<b-loading :active="true" :is-full-page="false" />
</div>
<div v-else>
<!-- Summary Stats -->
<div class="columns">
<div class="column is-4">
<div class="box has-text-centered">
<p class="heading">{{ $t('globals.terms.campaigns') }}</p>
<p class="title">{{ activity.campaignViews ? activity.campaignViews.length : 0 }}</p>
</div>
</div>
<div class="column is-4">
<div class="box has-text-centered">
<p class="heading">{{ $t('campaigns.views') }}</p>
<p class="title">{{ totalViews }}</p>
</div>
</div>
<div class="column is-4">
<div class="box has-text-centered">
<p class="heading">{{ $t('campaigns.clicks') }}</p>
<p class="title">{{ totalClicks }}</p>
</div>
</div>
</div>
<!-- Campaign Views Section -->
<div class="section-header mb-4">
<h5 class="title is-5">
{{ $t('campaigns.views') }}
</h5>
</div>
<div v-if="activity.campaignViews && activity.campaignViews.length > 0">
<b-table :data="activity.campaignViews" hoverable default-sort="lastViewedAt" default-sort-direction="desc"
paginated :per-page="10" :pagination-simple="false" class="campaign-views-table">
<b-table-column v-slot="props" field="subject" :label="$tc('globals.terms.campaign', 1)" sortable>
<div v-if="props.row.uuid">
<router-link :to="{ name: 'campaign', params: { id: props.row.id } }">
{{ props.row.name }}
</router-link>
<p class="is-size-7 has-text-grey">{{ props.row.subject }}</p>
</div>
<div v-else>
<em class="has-text-grey">{{ $t('subscribers.activity.campaignDeleted') }}</em>
</div>
</b-table-column>
<b-table-column v-slot="props" field="viewCount" :label="$t('campaigns.views')" sortable numeric>
<span class="tag is-light">{{ props.row.viewCount }}</span>
</b-table-column>
<b-table-column v-slot="props" field="lastViewedAt" :label="$t('globals.fields.createdAt')" sortable>
<span v-if="props.row.lastViewedAt">
{{ $utils.niceDate(props.row.lastViewedAt, true) }}
</span>
</b-table-column>
</b-table>
</div>
<div v-else class="has-text-centered has-text-grey p-6">
<p class="mt-2">{{ $t('globals.messages.emptyState') }}</p>
</div>
<!-- Link Clicks Section -->
<div class="section-header mb-4 mt-6">
<h5 class="title is-5">
{{ $t('campaigns.clicks') }}
</h5>
</div>
<div v-if="activity.linkClicks && activity.linkClicks.length > 0">
<b-table :data="activity.linkClicks" hoverable default-sort="lastClickedAt" default-sort-direction="desc"
paginated :per-page="10" :pagination-simple="false" class="link-clicks-table">
<b-table-column v-slot="props" field="url" :label="$t('globals.terms.url')" cell-class="link-click-url"
sortable>
<a :href="props.row.url" target="_blank" rel="noopener noreferrer">
{{ props.row.url }}
</a>
</b-table-column>
<b-table-column v-slot="props" field="campaignName" :label="$tc('globals.terms.campaign', 1)" sortable>
<div v-if="props.row.campaignUuid">
<router-link :to="{ name: 'campaign', params: { id: props.row.campaignId } }">
{{ props.row.campaignSubject || props.row.campaignName }}
</router-link>
</div>
<div v-else>
&mdash;
</div>
</b-table-column>
<b-table-column v-slot="props" field="clickCount" :label="$t('campaigns.clicks')" sortable numeric>
<span class="tag is-light">{{ props.row.clickCount }}</span>
</b-table-column>
<b-table-column v-slot="props" field="lastClickedAt" :label="$t('globals.fields.createdAt')" sortable>
<span v-if="props.row.lastClickedAt">
{{ $utils.niceDate(props.row.lastClickedAt, true) }}
</span>
</b-table-column>
</b-table>
</div>
<div v-else class="has-text-centered has-text-grey p-6">
<p class="mt-2">{{ $t('globals.messages.emptyState') }}</p>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue';
export default Vue.extend({
props: {
subscriberId: {
type: Number,
required: true,
},
},
data() {
return {
isLoading: false,
activity: {
campaignViews: [],
linkClicks: [],
},
};
},
computed: {
totalViews() {
if (!this.activity.campaignViews) return 0;
return this.activity.campaignViews.reduce((sum, v) => sum + (v.viewCount || 0), 0);
},
totalClicks() {
if (!this.activity.linkClicks) return 0;
return this.activity.linkClicks.reduce((sum, c) => sum + (c.clickCount || 0), 0);
},
},
mounted() {
this.getActivity();
},
methods: {
getActivity() {
this.isLoading = true;
this.$api.getSubscriberActivity(this.subscriberId).then((data) => {
this.activity = data;
this.isLoading = false;
}).catch(() => {
this.isLoading = false;
});
},
},
});
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="visual-editor-wrapper">
<iframe ref="visualEditor" id="visual-editor" class="visual-editor email-builder-container"
title="Visual email editor" />
<!-- image picker -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
<div class="modal-card content" style="width: auto">
<section expanded class="modal-card-body">
<media is-modal @selected="onMediaSelect" />
</section>
</div>
</b-modal>
</div>
</template>
<script>
import Media from '../views/Media.vue';
export default {
components: {
Media,
},
props: {
source: { type: String, default: '' },
height: { type: String, default: 'auto' },
},
data() {
return {
isMediaVisible: false,
};
},
methods: {
loadScript() {
return new Promise((resolve, reject) => {
const iframe = this.$refs.visualEditor;
if (iframe.contentWindow.EmailBuilder) {
resolve();
return;
}
const script = document.createElement('script');
script.id = 'email-builder-script';
script.src = '/admin/static/email-builder/email-builder.umd.js';
script.onload = () => {
resolve();
};
script.onerror = reject;
// Append script to iframe's head
iframe.contentDocument.head.appendChild(script);
});
},
render(source) {
const iframe = this.$refs.visualEditor;
// If the editor is not-rendered, render it the first time. This can happen
// on first loads and importing an email template via render().
const em = iframe.contentWindow.EmailBuilder;
if (!em || !em.isRendered('visual-editor-container')) {
iframe.contentWindow.EmailBuilder.render('visual-editor-container', {
data: {},
onChange: (data, body) => {
// Hack to fix quotes in Go {{ templating }} in the HTML body.
const tpl = body.replace(/\{\{[^}]*\}\}/g, (match) => match.replace(/&quot;/g, '"'));
this.$emit('change', { source: JSON.stringify(data), body: tpl });
},
});
}
if (!source) {
return;
}
// setDocument() will trigger onChange() that produces both bodySource and body (HTML).
// On init, the `data: source` above sets the content in the editor, but doesn't trigger
// onChange(), which is required to set the source+HTML state in the parent for preview to work.
// Couldn't figure out if there was an on load/on init event etc. in email-builder, so brute force it
// with a timer.
let n = 10;
const timer = window.setInterval(() => {
const container = iframe.contentWindow.document.getElementById('visual-editor-container');
if (container && container.hasChildNodes()) {
em.resetDocument(source);
window.clearInterval(timer);
return;
}
n += 1;
if (n > 10) {
window.clearInterval(timer);
}
}, 100);
},
// Inject media URL into the image URL input field in the visual edior sidebar.
onMediaSelect(media) {
const iframe = this.$refs.visualEditor;
const input = iframe.contentDocument.querySelector('.image-url input');
if (input) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
).set;
nativeInputValueSetter.call(input, media.url);
const inputEvent = new Event('input', { bubbles: true });
input.dispatchEvent(inputEvent);
}
},
// Observe DOM changes in the iframe to inject media selector
// into the image URL input fields.
onSidebarMount(msg) {
if (!msg.data) {
return;
}
if (msg.data === 'visualeditor.select-media') {
this.isMediaVisible = true;
}
},
},
mounted() {
// Initialize iframe content
const iframe = this.$refs.visualEditor;
iframe.style.height = this.height;
// Set basic iframe HTML structure
iframe.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; }
#visual-editor-container { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="visual-editor-container"></div>
</body>
</html>
`;
iframe.onload = () => {
this.loadScript().then(() => {
let source = null;
if (this.$props.source) {
source = JSON.parse(this.$props.source);
}
this.render(source);
}).catch((error) => {
/* eslint-disable-next-line no-console */
console.error('Failed to load email-builer script:', error);
});
};
window.addEventListener('message', this.onSidebarMount, false);
},
unmounted() {
window.removeEventListener('message', this.onSidebarMount, false);
},
};
</script>
<style lang="css">
.visual-editor-wrapper {
width: 100%;
border: 1px solid #eaeaea;
max-width: 100vw;
}
#visual-editor {
position: relative;
border: none;
width: 100%;
min-height: 500px;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
// Forked from https://github.com/fsegurai/codemirror-themes
// MIT License - Copyright (c) 2025 fsegurai
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags } from '@lezer/highlight';
// VSCode Light theme color definitions
const background = '#ffffff';
const foreground = '#383a42';
const caret = '#000000';
const selection = '#add6ff';
const selectionMatch = '#a8ac94';
const lineHighlight = '#99999926';
const gutterBackground = '#ffffff';
const gutterForeground = '#1b3a5b';
const gutterActiveForeground = '#0b216f';
const keywordColor = '#1b3a5b';
const controlKeywordColor = '#af00db';
const variableColor = '#e45649';
const classTypeColor = '#1b3a5b';
const functionColor = '#795e26';
const numberColor = '#098658';
const operatorColor = '#383a42';
const regexpColor = '#af00db';
const stringColor = '#50a14f';
const commentColor = '#999';
const linkColor = '#1b3a5b';
const invalidColor = '#e45649';
// Define the editor theme styles for VSCode Light
const vsCodeLightTheme = /* @__PURE__ */EditorView.theme({
'&': {
color: foreground,
backgroundColor: background,
fontFamily: 'Menlo, Monaco, Consolas, "Andale Mono", "Ubuntu Mono", "Courier New", monospace',
},
'.cm-content': {
caretColor: caret,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: caret,
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: selection,
},
'.cm-searchMatch': {
backgroundColor: selectionMatch,
outline: `1px solid ${lineHighlight}`,
},
'.cm-activeLine': {
backgroundColor: lineHighlight,
},
'.cm-gutters': {
backgroundColor: gutterBackground,
color: gutterForeground,
},
'.cm-activeLineGutter': {
color: gutterActiveForeground,
},
}, { dark: false });
// Define the highlighting style for code in the VSCode Light theme
const vsCodeLightHighlightStyle = /* @__PURE__ */HighlightStyle.define([
{
tag: [
tags.keyword,
tags.operatorKeyword,
tags.modifier,
tags.color,
/* @__PURE__ */tags.constant(tags.name),
/* @__PURE__ */tags.standard(tags.name),
/* @__PURE__ */tags.standard(tags.tagName),
/* @__PURE__ */tags.special(tags.brace),
tags.atom,
tags.bool,
/* @__PURE__ */tags.special(tags.variableName),
],
color: keywordColor,
},
{ tag: [tags.moduleKeyword, tags.controlKeyword], color: controlKeywordColor },
{
tag: [
tags.name,
tags.deleted,
tags.character,
tags.macroName,
tags.propertyName,
tags.variableName,
tags.labelName,
/* @__PURE__ */tags.definition(tags.name),
],
color: variableColor,
},
{ tag: tags.heading, fontWeight: 'bold', color: variableColor },
{
tag: [
tags.typeName,
tags.className,
tags.tagName,
tags.number,
tags.changed,
tags.annotation,
tags.self,
tags.namespace,
],
color: classTypeColor,
},
{
tag: [/* @__PURE__ */tags.function(tags.variableName), /* @__PURE__ */tags.function(tags.propertyName)],
color: functionColor,
},
{ tag: [tags.number], color: numberColor },
{
tag: [tags.operator, tags.punctuation, tags.separator, tags.url, tags.escape, tags.regexp],
color: operatorColor,
},
{ tag: [tags.regexp], color: regexpColor },
{
tag: [/* @__PURE__ */tags.special(tags.string), tags.processingInstruction, tags.string, tags.inserted],
color: stringColor,
},
{ tag: [tags.meta, tags.comment], color: commentColor },
{ tag: tags.link, color: linkColor, textDecoration: 'underline' },
{ tag: tags.invalid, color: invalidColor },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
]);
// Extension to enable the VSCode Light theme (both the editor theme and the highlight style)
const vsCodeLight = [
vsCodeLightTheme,
/* @__PURE__ */syntaxHighlighting(vsCodeLightHighlightStyle),
];
export { vsCodeLight, vsCodeLightHighlightStyle, vsCodeLightTheme };
+74
View File
@@ -0,0 +1,74 @@
const markdownToVisualBlock = (markdown) => {
const lines = markdown.split('\n');
const blocks = [];
const idBase = Date.now();
let textBuf = [];
const createBlock = (type, props, style = {}) => ({
id: `block-${idBase + blocks.length}`,
type,
data: {
props,
style: {
padding: {
top: 16, bottom: 16, right: 24, left: 24,
},
...style,
},
},
});
const flushText = () => {
if (textBuf.length > 0) {
blocks.push(createBlock('Text', { markdown: true, text: textBuf.join('\n') }));
textBuf = [];
}
};
lines.forEach((line) => {
// Handle ATX headings (# Heading)
const heading = line.match(/^(#+)\s+(.*)/);
if (heading) {
flushText();
blocks.push(createBlock('Heading', {
text: heading[2],
level: `h${Math.min(heading[1].length, 6)}`,
}));
return;
}
// Handle Setext headings (===== or -----)
const trimmed = line.trim();
if (/^(=+|-+)$/.test(trimmed) && textBuf.length > 0) {
const lastLine = textBuf.pop();
if (lastLine.trim()) {
flushText();
blocks.push(createBlock('Heading', {
text: lastLine,
level: trimmed[0] === '=' ? 'h1' : 'h2',
}));
return;
}
textBuf.push(lastLine, line);
} else {
textBuf.push(line);
}
});
flushText();
return {
root: {
type: 'EmailLayout',
data: { childrenIds: blocks.map((b) => b.id) },
},
...Object.fromEntries(blocks.map((b) => [b.id, { type: b.type, data: b.data }])),
};
};
export default markdownToVisualBlock;
+53
View File
@@ -0,0 +1,53 @@
export const models = Object.freeze({
serverConfig: 'serverConfig',
lang: 'lang',
dashboard: 'dashboard',
// This loading state is used across all contexts where lists are loaded
// via the instant "minimal" API.
lists: 'lists',
// This is used only on the lists page where lists are loaded with full
// context (subscriber counts), which can be slow and expensive.
listsFull: 'listsFull',
subscribers: 'subscribers',
campaigns: 'campaigns',
templates: 'templates',
media: 'media',
bounces: 'bounces',
users: 'users',
profile: 'profile',
userRoles: 'userRoles',
listRoles: 'listRoles',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',
});
// Ad-hoc URIs that are used outside of vuex requests.
const rootURL = import.meta.env.VUE_APP_ROOT_URL || '/';
const baseURL = import.meta.env.BASE_URL.replace(/\/$/, '');
export const uris = Object.freeze({
previewCampaign: '/api/campaigns/:id/preview',
previewCampaignArchive: '/api/campaigns/:id/preview/archive',
previewTemplate: '/api/templates/:id/preview',
previewRawTemplate: '/api/templates/preview',
exportSubscribers: '/api/subscribers/export',
errorEvents: '/api/events?type=error',
base: `${baseURL}/static`,
root: rootURL,
static: `${baseURL}/static`,
});
// Keys used in Vuex store.
export const storeKeys = Object.freeze({
models: 'models',
isLoading: 'isLoading',
});
export const timestamp = 'ddd D MMM YYYY, hh:mm A';
export const colors = Object.freeze({
primary: '#1b3a5b',
});
export const regDuration = '[0-9]+(ms|s|m|h|d)';
+136
View File
@@ -0,0 +1,136 @@
import Vue from 'vue';
import Buefy from 'buefy';
import VueI18n from 'vue-i18n';
import App from './App.vue';
import router from './router';
import store from './store';
import * as api from './api';
import Utils from './utils';
// Internationalisation.
Vue.use(VueI18n);
const i18n = new VueI18n();
Vue.use(Buefy, {});
Vue.config.productionTip = false;
// Setup the router.
router.beforeEach((to, from, next) => {
if (to.matched.length === 0) {
next('/404');
} else {
next();
}
});
router.afterEach((to) => {
Vue.nextTick(() => {
const t = to.meta.title && i18n.te(to.meta.title) ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${t} EagleCast`;
});
});
async function initConfig(app) {
// Load logged in user profile, server side config, and the language file before mounting the app.
const [profile, cfg] = await Promise.all([api.getUserProfile(), api.getServerConfig()]);
const lang = await api.getLang(cfg.lang);
i18n.locale = cfg.lang;
i18n.setLocaleMessage(i18n.locale, lang);
Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;
Vue.prototype.$events = app;
// $can('permission:name') is used in the UI to check whether the logged in user
// has a certain permission to toggle visibility of UI objects and UI functionality.
Vue.prototype.$can = (...perms) => {
if (profile.userRole.id === 1) {
return true;
}
// If the perm ends with a wildcard, check whether at least one permission
// in the group is present. Eg: campaigns:* will return true if at least
// one of campaigns:get, campaigns:manage etc. are present.
return perms.some((perm) => {
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.userRole.permissions.some((p) => p.startsWith(group));
}
return profile.userRole.permissions.includes(perm);
});
};
Vue.prototype.$canList = (id, perm) => {
if (profile.userRole.id === 1) {
return true;
}
// If the user role has global list permissions, return true.
const can = Vue.prototype.$can('lists:get_all', 'lists:manage_all');
if (can) {
return true;
}
return profile.listRole.lists.some((list) => list.id === id && list.permissions.includes(perm));
};
// Set the page title after i18n has loaded.
const to = router.history.current;
const title = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${title} EagleCast`;
if (app) {
app.$mount('#app');
}
}
const v = new Vue({
router,
store,
i18n,
render: (h) => h(App),
data: {
isLoaded: false,
},
methods: {
loadConfig() {
initConfig();
},
// awaitRestart handles app restart polling after settings changes.
// Shows a toast and polls until the backend is back up.
// Returns a promise that resolves with { needsRestart: boolean }.
awaitRestart(response) {
return new Promise((resolve) => {
// If there are running campaigns, app won't auto restart.
if (response && typeof response === 'object' && response.needsRestart) {
this.loadConfig();
resolve({ needsRestart: true });
return;
}
Vue.prototype.$utils.toast(i18n.t('settings.messengers.messageSaved'));
// Poll until backend is back up.
const pollId = setInterval(() => {
api.getHealth().then(() => {
clearInterval(pollId);
this.loadConfig();
resolve({ needsRestart: false });
});
}, 1000);
});
},
},
mounted() {
v.isLoaded = true;
},
});
initConfig(v);
+155
View File
@@ -0,0 +1,155 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
// The meta.group param is used in App.vue to expand menu group by name.
const routes = [
{
path: '/404',
name: '404_page',
meta: { title: '404' },
component: () => import('../views/404.vue'),
},
{
path: '/',
name: 'dashboard',
meta: { title: '' },
component: () => import('../views/Dashboard.vue'),
},
{
path: '/lists',
name: 'lists',
meta: { title: 'globals.terms.lists', group: 'lists' },
component: () => import('../views/Lists.vue'),
},
{
path: '/lists/forms',
name: 'forms',
meta: { title: 'forms.title', group: 'lists' },
component: () => import('../views/Forms.vue'),
},
{
path: '/lists/:id',
name: 'list',
meta: { title: 'globals.terms.lists', group: 'lists' },
component: () => import('../views/Lists.vue'),
},
{
path: '/subscribers',
name: 'subscribers',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import('../views/Subscribers.vue'),
},
{
path: '/subscribers/import',
name: 'import',
meta: { title: 'import.title', group: 'subscribers' },
component: () => import('../views/Import.vue'),
},
{
path: '/subscribers/bounces',
name: 'bounces',
meta: { title: 'globals.terms.bounces', group: 'subscribers' },
component: () => import('../views/Bounces.vue'),
},
{
path: '/subscribers/lists/:listID',
name: 'subscribers_list',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import('../views/Subscribers.vue'),
},
{
path: '/subscribers/:id',
name: 'subscriber',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import('../views/Subscribers.vue'),
},
{
path: '/campaigns',
name: 'campaigns',
meta: { title: 'globals.terms.campaigns', group: 'campaigns' },
component: () => import('../views/Campaigns.vue'),
},
{
path: '/campaigns/media',
name: 'media',
meta: { title: 'globals.terms.media', group: 'campaigns' },
component: () => import('../views/Media.vue'),
},
{
path: '/campaigns/templates',
name: 'templates',
meta: { title: 'globals.terms.templates', group: 'campaigns' },
component: () => import('../views/Templates.vue'),
},
{
path: '/campaigns/analytics',
name: 'campaignAnalytics',
meta: { title: 'analytics.title', group: 'campaigns' },
component: () => import('../views/CampaignAnalytics.vue'),
},
{
path: '/campaigns/:id',
name: 'campaign',
meta: { title: 'globals.terms.campaign', group: 'campaigns' },
component: () => import('../views/Campaign.vue'),
},
{
path: '/user/profile',
name: 'userProfile',
meta: { title: 'users.profile', group: 'settings' },
component: () => import('../views/UserProfile.vue'),
},
{
path: '/settings',
name: 'settings',
meta: { title: 'globals.terms.settings', group: 'settings' },
component: () => import('../views/Settings.vue'),
},
{
path: '/settings/logs',
name: 'logs',
meta: { title: 'logs.title', group: 'settings' },
component: () => import('../views/Logs.vue'),
},
{
path: '/users',
name: 'users',
meta: { title: 'globals.terms.users', group: 'users' },
component: () => import('../views/Users.vue'),
},
{
path: '/users/roles/users',
name: 'userRoles',
meta: { title: 'users.userRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
path: '/users/roles/lists',
name: 'listRoles',
meta: { title: 'users.listRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
path: '/settings/maintenance',
name: 'maintenance',
meta: { title: 'maintenance.title', group: 'settings' },
component: () => import('../views/Maintenance.vue'),
},
];
const router = new VueRouter({
mode: 'history',
base: import.meta.env.BASE_URL,
routes,
scrollBehavior(to) {
if (to.hash) {
return { selector: to.hash };
}
return { x: 0, y: 0 };
},
});
export default router;
+55
View File
@@ -0,0 +1,55 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { models } from '../constants';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
// Data from API responses for different models, eg: lists, campaigns.
// The API responses are stored in this map as-is. This is invoked by
// API requests in `http`. This initialises lists: {}, campaigns: {}
// etc. on state.
...Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: [] }), {}),
// Map of loading status (true, false) indicators for different model keys
// like lists, campaigns etc. loading: {lists: true, campaigns: true ...}.
// The Axios API global request interceptor marks a model as loading=true
// and the response interceptor marks it as false. The model keys are being
// pre-initialised here to fix "reactivity" issues on first loads.
loading: Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: false }), {}),
},
mutations: {
// Set data from API responses. `model` is 'lists', 'campaigns' etc.
setModelResponse(state, { model, data }) {
state[model] = data;
},
// Set the loading status for a model globally. When a request starts,
// status is set to true which is used by the UI to show loaders and block
// forms. When a response is received, the status is set to false. This is
// invoked by API requests in `http`.
setLoading(state, { model, status }) {
state.loading[model] = status;
},
},
getters: {
[models.lists]: (state) => state[models.lists],
[models.subscribers]: (state) => state[models.subscribers],
[models.campaigns]: (state) => state[models.campaigns],
[models.media]: (state) => state[models.media],
[models.templates]: (state) => state[models.templates],
[models.users]: (state) => state[models.users],
[models.profile]: (state) => state[models.profile],
[models.userRoles]: (state) => state[models.userRoles],
[models.listRoles]: (state) => state[models.listRoles],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
[models.logs]: (state) => state[models.logs],
},
modules: {
},
});
+252
View File
@@ -0,0 +1,252 @@
import {
DialogProgrammatic as Dialog,
ToastProgrammatic as Toast,
} from 'buefy';
import dayjs from 'dayjs';
import dayDuration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import updateLocale from 'dayjs/plugin/updateLocale';
dayjs.extend(updateLocale);
dayjs.extend(relativeTime);
dayjs.extend(dayDuration);
const reEmail = /(.+?)@(.+?)/ig;
const prefKey = 'eaglecast_pref';
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
export default class Utils {
constructor(i18n) {
this.i18n = i18n;
this.intlNumFormat = new Intl.NumberFormat();
if (i18n) {
dayjs.updateLocale('en', {
relativeTime: {
future: '%s',
past: '%s',
s: `${i18n.tc('globals.terms.second', 2)}`,
m: `1 ${i18n.tc('globals.terms.minute', 1)}`,
mm: `%d ${i18n.tc('globals.terms.minute', 2)}`,
h: `1 ${i18n.tc('globals.terms.hour', 1)}`,
hh: `%d ${i18n.tc('globals.terms.hour', 2)}`,
d: `1 ${i18n.tc('globals.terms.day', 1)}`,
dd: `%d ${i18n.tc('globals.terms.day', 2)}`,
M: `1 ${i18n.tc('globals.terms.month', 1)}`,
MM: `%d ${i18n.tc('globals.terms.month', 2)}`,
y: `${i18n.tc('globals.terms.year', 1)}`,
yy: `%d ${i18n.tc('globals.terms.year', 2)}`,
},
});
}
}
getDate = (d) => dayjs(d);
// Parses an ISO timestamp to a simpler form.
niceDate = (stamp, showTime) => {
if (!stamp) {
return '';
}
const d = dayjs(stamp);
const day = this.i18n.t(`globals.days.${d.day() + 1}`);
const month = this.i18n.t(`globals.months.${d.month() + 1}`);
let out = d.format(`[${day},] DD [${month}] YYYY`);
if (showTime) {
out += d.format(', HH:mm');
}
return out;
};
duration = (start, end) => {
const a = dayjs(start);
const b = dayjs(end);
const d = dayjs.duration(Math.abs(b.diff(a)));
const parts = [
Math.floor(d.asDays()) && `${Math.floor(d.asDays())}d`,
d.hours() && `${d.hours()}h`,
d.minutes() && `${d.minutes()}m`,
d.seconds() && `${d.seconds()}s`,
].filter(Boolean);
return `${b.isBefore(a) ? '-' : ''}${parts.join(' ')}`;
};
// Simple, naive, e-mail address check.
validateEmail = (e) => e.match(reEmail);
niceNumber = (n) => {
if (n === null || n === undefined) {
return 0;
}
let pfx = '';
let div = 1;
if (n >= 1.0e+9) {
pfx = 'b';
div = 1.0e+9;
} else if (n >= 1.0e+6) {
pfx = 'm';
div = 1.0e+6;
} else if (n >= 1.0e+4) {
pfx = 'k';
div = 1.0e+3;
} else {
return n;
}
// Whole number without decimals.
const out = (n / div);
if (Math.floor(out) === n) {
return out + pfx;
}
return out.toFixed(2) + pfx;
};
formatNumber(v) {
return this.intlNumFormat.format(v);
}
// Parse one or more numeric ids as query params and return as an array of ints.
parseQueryIDs = (ids) => {
if (!ids) {
return [];
}
if (typeof ids === 'string') {
return [parseInt(ids, 10)];
}
if (typeof ids === 'number') {
return [parseInt(ids, 10)];
}
return ids.map((id) => parseInt(id, 10));
};
// https://stackoverflow.com/a/12034334
escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);
titleCase = (str) => str[0].toUpperCase() + str.substr(1).toLowerCase();
// UI shortcuts.
confirm = (msg, onConfirm, onCancel) => {
Dialog.confirm({
scroll: 'keep',
message: !msg ? this.i18n.t('globals.messages.confirm') : this.escapeHTML(msg),
confirmText: this.i18n.t('globals.buttons.ok'),
cancelText: this.i18n.t('globals.buttons.cancel'),
onConfirm,
onCancel,
});
};
prompt = (msg, inputAttrs, onConfirm, onCancel, params) => {
const p = params || {};
Dialog.prompt({
scroll: 'keep',
message: this.escapeHTML(msg),
confirmText: p.confirmText || this.i18n.t('globals.buttons.ok'),
cancelText: p.cancelText || this.i18n.t('globals.buttons.cancel'),
inputAttrs: {
type: 'string',
maxlength: 200,
...inputAttrs,
},
trapFocus: true,
onConfirm,
onCancel,
});
};
toast = (msg, typ, duration, queue) => {
Toast.open({
message: this.escapeHTML(msg),
type: !typ ? 'is-success' : typ,
queue,
duration: duration || 3000,
position: 'is-top',
pauseOnHover: true,
});
};
// Takes a props.row from a Buefy b-column <td> template and
// returns a `data-id` attribute which Buefy then applies to the td.
tdID = (row) => ({ 'data-id': row.id.toString() });
camelString = (str) => {
const s = str.replace(/[-_\s]+(.)?/g, (match, chr) => (chr ? chr.toUpperCase() : ''));
return s.substr(0, 1).toLowerCase() + s.substr(1);
};
// camelKeys recursively camelCases all keys in a given object (array or {}).
// For each key it traverses, it passes a dot separated key path to an optional testFunc() bool.
// so that it can camelcase or leave a particular key alone based on what testFunc() returns.
// eg: The keypath for {"data": {"results": ["created_at": 123]}} is
// .data.results.*.created_at (array indices become *)
// testFunc() can examine this key and return true to convert it to camelcase
// or false to leave it as-is.
camelKeys = (obj, testFunc, keys) => {
if (obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((o) => this.camelKeys(o, testFunc, `${keys || ''}.*`));
}
if (obj.constructor === Object) {
return Object.keys(obj).reduce((result, key) => {
const keyPath = `${keys || ''}.${key}`;
let k = key;
// If there's no testfunc or if a function is defined and it returns true, convert.
if (testFunc === undefined || testFunc(keyPath)) {
k = this.camelString(key);
}
return {
...result,
[k]: this.camelKeys(obj[key], testFunc, keyPath),
};
}, {});
}
return obj;
};
getPref = (key) => {
if (localStorage.getItem(prefKey) === null) {
return null;
}
const p = JSON.parse(localStorage.getItem(prefKey));
return key in p ? p[key] : null;
};
setPref = (key, val) => {
let p = {};
if (localStorage.getItem(prefKey) !== null) {
p = JSON.parse(localStorage.getItem(prefKey));
}
p[key] = val;
localStorage.setItem(prefKey, JSON.stringify(p));
};
}
+14
View File
@@ -0,0 +1,14 @@
<template>
<section class="page-404">
<h1 class="title">
404
</h1>
</section>
</template>
<script>
import Vue from 'vue';
export default Vue.extend({
});
</script>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
+236
View File
@@ -0,0 +1,236 @@
<template>
<section class="bounces">
<header class="page-header columns">
<div class="column is-two-thirds">
<h1 class="title is-4">
{{ $t('globals.terms.bounces') }}
<span v-if="bounces.total > 0">({{ bounces.total }})</span>
</h1>
</div>
</header>
<b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces" default-sort="createdAt" checkable
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" detailed show-detail-icon
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="bounces.perPage" :total="bounces.total" backend-sorting
@sort="onSort">
<template #top-left>
<div class="actions">
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="$utils.confirm(null, () => deleteBounces())" data-cy="btn-delete">
<b-icon icon="trash-can-outline" size="is-small" /> {{ $t('globals.buttons.delete') }}
</a>
<a class="a" href="#" @click.prevent="$utils.confirm(null, () => blocklistSubscribers())"
data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> {{ $t('import.blocklist') }}
</a>
<span>
{{ $t('globals.messages.numSelected', { num: numSelectedBounces }) }}
<span v-if="!bulk.all && bounces.total > bounces.perPage">
&mdash;
<a href="#" @click.prevent="selectAllBounces">
{{ $t('subscribers.selectAll', { num: bounces.total }) }}
</a>
</span>
</span>
</template>
</div>
</template>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" :td-attrs="$utils.tdID" sortable>
<router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId } }"
:class="{ 'blocklisted': props.row.subscriberStatus === 'blocklisted' }">
{{ props.row.email }}
<b-tag v-if="props.row.subscriberStatus !== 'enabled'" :class="props.row.subscriberStatus"
data-cy="blocklisted">
{{ $t(`subscribers.status.${props.row.subscriberStatus}`) }}
</b-tag>
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="campaign" :label="$tc('globals.terms.campaign')" sortable>
<router-link v-if="props.row.campaign" :to="{ name: 'bounces', query: { campaign_id: props.row.campaign.id } }">
{{ props.row.campaign.name }}
</router-link>
<span v-else>-</span>
</b-table-column>
<b-table-column v-slot="props" field="source" :label="$t('bounces.source')" sortable>
<router-link :to="{ name: 'bounces', query: { source: props.row.source } }">
{{ props.row.source }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
<router-link :to="{ name: 'bounces', query: { type: props.row.type } }">
{{ $t(`bounces.${props.row.type}`) }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt, true) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a v-if="!props.row.isDefault" href="#" @click.prevent="$utils.confirm(null, () => deleteBounce(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="trash-can-outline" size="is-small" />
</span>
</div>
</b-table-column>
<template #detail="props">
<pre class="is-size-7">{{ props.row.meta }}</pre>
</template>
<template #empty v-if="!loading.templates">
<empty-placeholder />
</template>
</b-table>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
},
data() {
return {
bounces: {},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
// Query params to filter the getSubscribers() API call.
queryParams: {
page: 1,
orderBy: 'created_at',
order: 'desc',
campaignID: 0,
source: '',
},
};
},
methods: {
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getBounces();
},
onPageChange(p) {
this.queryParams.page = p;
this.getBounces();
},
// Mark all bounces in the query as selected.
selectAllBounces() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.bounces.total) {
this.bulk.all = false;
}
},
getBounces() {
this.bulk.checked = [];
this.bulk.all = false;
this.$api.getBounces({
page: this.queryParams.page,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
campaign_id: this.queryParams.campaign_id,
source: this.queryParams.source,
}).then((data) => {
this.bounces = data;
});
},
deleteBounce(b) {
this.$api.deleteBounce(b.id).then(() => {
this.getBounces();
this.$utils.toast(this.$t('globals.messages.deleted', { name: b.email }));
});
},
deleteBounces() {
const params = {};
if (!this.bulk.all && this.bulk.checked.length > 0) {
params.id = this.bulk.checked.map((s) => s.id);
} else if (this.bulk.all) {
params.all = true;
}
this.$api.deleteBounces(params).then(() => {
this.getBounces();
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.bounces'), num: this.numSelectedBounces },
));
});
},
blocklistSubscribers() {
const cb = () => {
this.getBounces();
this.$utils.toast(this.$t('globals.messages.done'));
};
if (!this.bulk.all && this.bulk.checked.length > 0) {
const subIds = this.bulk.checked.map((s) => s.subscriberId);
this.$api.blocklistSubscribers({ ids: subIds }).then(cb);
return;
}
this.$api.blocklistBouncedSubscribers({ all: true }).then(cb);
},
},
computed: {
...mapState(['templates', 'loading']),
numSelectedBounces() {
if (this.bulk.all) {
return this.bounces.total;
}
return this.bulk.checked.length;
},
},
created() {
this.$root.$on('page.refresh', this.getBounces);
},
destroyed() {
this.$root.$off('page.refresh', this.getBounces);
},
mounted() {
if (this.$route.query.campaign_id) {
this.queryParams.campaign_id = parseInt(this.$route.query.campaign_id, 10);
}
if (this.$route.query.source) {
this.queryParams.source = this.$route.query.source;
}
this.getBounces();
},
});
</script>
+827
View File
@@ -0,0 +1,827 @@
<template>
<section class="campaign">
<header class="columns page-header">
<div class="column is-6">
<p v-if="isEditing && data.status" class="tags">
<b-tag v-if="isEditing" :class="data.status">
{{ $t(`campaigns.status.${data.status}`) }}
</b-tag>
<b-tag v-if="data.type === 'optin'" :class="data.type">
{{ $t('lists.optin') }}
</b-tag>
<span v-if="isEditing" class="has-text-grey-light is-size-7" :data-campaign-id="data.id">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
{{ $t('globals.fields.uuid') }}: <copy-text :text="data.uuid" />
</span>
</p>
<h4 v-if="isEditing" class="title is-4">
{{ data.name }}
</h4>
<h4 v-else class="title is-4">
{{ $t('campaigns.newCampaign') }}
</h4>
</div>
<div class="column is-6">
<div v-if="canManage || canSend" class="buttons">
<b-field grouped v-if="isEditing && canEdit">
<b-field v-if="canManage" expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-save" aria-keyshortcuts="ctrl+s">
<span class="has-kbd">{{ $t('globals.buttons.saveChanges') }} <span class="kbd">Ctrl+S</span></span>
</b-button>
</b-field>
<b-field expanded v-if="canSend && canStart">
<b-button expanded @click="startCampaign" :loading="loading.campaigns" type="is-primary"
icon-left="rocket-launch-outline" data-cy="btn-start">
{{ $t('campaigns.start') }}
</b-button>
</b-field>
<b-field expanded v-if="canSend && canSchedule">
<b-button expanded @click="startCampaign" :loading="loading.campaigns" type="is-primary"
icon-left="clock-start" data-cy="btn-schedule">
{{ $t('campaigns.schedule') }}
</b-button>
</b-field>
<b-field expanded v-if="canSend && canUnSchedule">
<b-button expanded @click="$utils.confirm(null, unscheduleCampaign)" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start" data-cy="btn-unschedule">
{{ $t('campaigns.unSchedule') }}
</b-button>
</b-field>
</b-field>
</div>
</div>
</header>
<b-loading :active="loading.campaigns" />
<b-tabs type="is-boxed" :animated="false" v-model="activeTab" @input="onTab">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border" value="campaign"
icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="() => onSubmit(isNew ? 'create' : 'update')">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name" :disabled="!canEdit"
:placeholder="$t('globals.fields.name')" required autofocus />
</b-field>
<b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="5000" v-model="form.subject" name="subject" :disabled="!canEdit"
:placeholder="$t('campaigns.subject')" required />
</b-field>
<b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" name="from_email" :disabled="!canEdit"
:placeholder="$t('campaigns.fromAddressPlaceholder')" required />
</b-field>
<list-selector v-model="form.lists" :selected="form.lists" :all="lists.results" :disabled="!canEdit"
:label="$t('globals.terms.lists')" :placeholder="$t('campaigns.sendToLists')" />
<div class="columns">
<div class="column is-6">
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger" name="messenger"
:disabled="!canEdit" required expanded>
<template v-if="emailMessengers.length > 1">
<optgroup label="email">
<option v-for="m in emailMessengers" :value="m" :key="m">
{{ m }}
</option>
</optgroup>
</template>
<template v-else>
<option value="email">email</option>
</template>
<option v-for="m in otherMessengers" :value="m" :key="m">{{ m }}</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('campaigns.format')" label-position="on-border" class="mr-4 mb-0">
<b-select v-model="form.content.contentType" :disabled="!canEdit || isEditing" value="richtext"
expanded>
<option v-for="(name, f) in contentTypes" :key="f" name="format" :value="f"
:data-cy="`check-${f}`">
{{ name }}
</option>
</b-select>
</b-field>
</div>
</div>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" :disabled="!canEdit" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" />
</b-field>
<hr />
<div class="columns">
<div class="column is-4">
<b-field :label="$t('campaigns.sendLater')" data-cy="btn-send-later">
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field>
</div>
<div class="column">
<br />
<b-field v-if="form.sendLater" data-cy="send_at"
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit" required editable mobile-native
position="is-top-right" :placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime"
horizontal-time-picker />
</b-field>
</div>
</div>
<div>
<p class="has-text-right">
<a href="#" @click.prevent="onShowHeaders" data-cy="btn-headers">
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}
</a>
</p>
<b-field v-if="form.headersStr !== '[]' || isHeadersVisible" label-position="on-border"
:message="$t('campaigns.customHeadersHelp')">
<b-input v-model="form.headersStr" name="headers" type="textarea"
placeholder="[{&quot;X-Custom&quot;: &quot;value&quot;}, {&quot;X-Custom2&quot;: &quot;value&quot;}]"
:disabled="!canEdit" />
</b-field>
</div>
<hr />
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary" :loading="loading.campaigns" data-cy="btn-continue">
{{ $t('campaigns.continue') }}
</b-button>
</b-field>
</form>
</div>
<div v-if="canManage" class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">
{{ $t('campaigns.sendTest') }}
</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<b-taginput v-model="form.testEmails" :before-adding="$utils.validateEmail" :disabled="isNew" ellipsis
icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
</b-field>
<b-field>
<b-button @click="() => onSubmit('test')" :loading="loading.campaigns" :disabled="isNew"
type="is-primary" icon-left="email-outline">
{{ $t('campaigns.send') }}
</b-button>
</b-field>
</div>
</div>
</div>
</section>
</b-tab-item><!-- campaign -->
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew" value="content">
<editor v-if="data.id" v-model="form.content" :id="data.id" :title="data.name" :disabled="!canEdit"
:templates="templates" :content-types="contentTypes" />
<div class="columns">
<div class="column is-6">
<p v-if="!isAttachFieldVisible" class="is-size-6 has-text-grey">
<a href="#" @click.prevent="onShowAttachField()" data-cy="btn-attach">
<b-icon icon="file-upload-outline" size="is-small" />
{{ $t('campaigns.addAttachments') }}
</a>
</p>
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border"
expanded data-cy="media">
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename"
@focus="onOpenAttach" :disabled="!canEdit" />
</b-field>
</div>
<div class="column has-text-right">
<span v-if="canEdit && form.content.contentType !== 'plain'" class="is-size-6 has-text-grey ml-6">
<a v-if="form.altbody === null" href="#" @click.prevent="onAddAltBody">
<b-icon icon="text" size="is-small" /> {{ $t('campaigns.addAltText') }}
</a>
<a v-else href="#" @click.prevent="$utils.confirm(null, onRemoveAltBody)">
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('campaigns.removeAltText') }}
</a>
</span>
</div>
</div>
<div v-if="canEdit && form.content.contentType !== 'plain'" class="alt-body">
<b-input v-if="form.altbody !== null" v-model="form.altbody" type="textarea" :disabled="!canEdit" />
</div>
</b-tab-item><!-- content -->
<b-tab-item :label="$t('globals.terms.attribs')" icon="code" value="attribs" :disabled="isNew">
<section class="wrap">
<b-field :label="$t('globals.terms.attribs')" :message="$t('campaigns.attribsHelp')"
label-position="on-border">
<b-input v-model="form.attribsStr" type="textarea" :disabled="!canEdit" rows="15" />
</b-field>
</section>
</b-tab-item><!-- attribs -->
<b-tab-item :label="$t('campaigns.archive')" icon="newspaper-variant-outline" value="archive" :disabled="isNew">
<section class="wrap">
<div class="columns">
<div class="column is-4">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive"
:message="$t('campaigns.archiveHelp')">
<div class="columns">
<div class="column">
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${serverConfig.root_url}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" />
</a>
</div>
</div>
</b-field>
</div>
<div class="column is-8">
<b-field grouped position="is-right">
<b-field v-if="!canEdit && canArchive">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-6">
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.archiveTemplateId" name="template"
:disabled="!canArchive || !form.archive || form.content.contentType === 'visual'" required>
<template v-for="t in templates">
<option v-if="t.type === 'campaign'" :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field grouped position="is-right">
<b-field v-if="form.archive && (!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}')">
<a class="button is-primary" href="#" @click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon
icon="code" /></a>
</b-field>
<b-field v-if="form.archive">
<b-button @click="onToggleArchivePreview" type="is-primary" icon-left="file-find-outline"
data-cy="btn-preview">
{{ $t('campaigns.preview') }}
</b-button>
</b-field>
</b-field>
</div>
</div>
<b-field>
<b-field :label="$t('campaigns.archiveSlug')" label-position="on-border"
:message="$t('campaigns.archiveSlugHelp')">
<b-input :maxlength="200" :ref="'focus'" v-model="form.archiveSlug" name="archive_slug"
data-cy="archive-slug" :disabled="!canArchive || !form.archive" />
</b-field>
</b-field>
<b-field :label="$t('campaigns.archiveMeta')" :message="$t('campaigns.archiveMetaHelp')"
label-position="on-border">
<b-input v-model="form.archiveMetaStr" name="archive_meta" type="textarea" data-cy="archive-meta"
:disabled="!canArchive || !form.archive" rows="20" />
</b-field>
</section>
</b-tab-item><!-- archive -->
</b-tabs>
<b-modal scroll="keep" :aria-modal="true" :active.sync="isAttachModalOpen" :width="900">
<div class="modal-card content" style="width: auto">
<section expanded class="modal-card-body">
<media is-modal @selected="onAttachSelect" />
</section>
</div>
</b-modal>
<campaign-preview v-if="isPreviewingArchive" @close="onToggleArchivePreview" type="campaign" :id="data.id"
:archive-meta="form.archiveMetaStr" :title="data.title" :content-type="data.contentType"
:template-id="form.archiveTemplateId" is-post is-archive />
</section>
</template>
<script>
import dayjs from 'dayjs';
import htmlToPlainText from 'textversionjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
import CopyText from '../components/CopyText.vue';
import Editor from '../components/Editor.vue';
import ListSelector from '../components/ListSelector.vue';
import Media from './Media.vue';
export default Vue.extend({
components: {
ListSelector,
Editor,
Media,
CopyText,
CampaignPreview,
},
data() {
return {
contentTypes: Object.freeze({
richtext: this.$t('campaigns.richText'),
html: this.$t('campaigns.rawHTML'),
markdown: this.$t('campaigns.markdown'),
plain: this.$t('campaigns.plainText'),
visual: this.$t('campaigns.visual'),
}),
isNew: false,
isEditing: false,
isHeadersVisible: false,
isAttachFieldVisible: false,
isAttachModalOpen: false,
isPreviewingArchive: false,
activeTab: 'campaign',
data: {},
// IDs from ?list_id query param.
selListIDs: [],
// Binds form input values.
form: {
archiveSlug: null,
name: '',
subject: '',
fromEmail: '',
headersStr: '[]',
headers: [],
attribsStr: '{}',
messenger: 'email',
lists: [],
tags: [],
sendAt: null,
content: {
contentType: 'richtext',
body: '',
bodySource: null,
templateId: null,
},
altbody: null,
media: [],
// Parsed Date() version of send_at from the API.
sendAtDate: null,
sendLater: false,
archive: false,
archiveMetaStr: '{}',
archiveMeta: {},
testEmails: [],
},
};
},
methods: {
formatDateTime(s) {
return dayjs(s).format('YYYY-MM-DD HH:mm');
},
onToggleArchivePreview() {
this.isPreviewingArchive = !this.isPreviewingArchive;
},
onAddAltBody() {
this.form.altbody = htmlToPlainText(this.form.content.body);
},
onRemoveAltBody() {
this.form.altbody = null;
},
onShowHeaders() {
this.isHeadersVisible = !this.isHeadersVisible;
},
onShowAttachField() {
this.isAttachFieldVisible = true;
this.$nextTick(() => {
this.$refs.media.focus();
});
},
onOpenAttach() {
this.isAttachModalOpen = true;
},
onAttachSelect(o) {
if (this.form.media.some((m) => m.id === o.id)) {
return;
}
this.form.media.push(o);
},
isUnsaved() {
return this.data.body !== this.form.content.body
|| this.data.contentType !== this.form.content.contentType;
},
onTab(tab) {
if (tab === 'content' && window.tinymce && window.tinymce.editors.length > 0) {
this.$nextTick(() => {
window.tinymce.editors[0].focus();
});
}
// this.$router.replace({ hash: `#${tab}` });
window.history.replaceState({}, '', `#${tab}`);
},
onFillArchiveMeta() {
const archiveStr = `{"email": "email@domain.com", "name": "${this.$t('globals.fields.name')}", "attribs": {}}`;
this.form.archiveMetaStr = this.$utils.getPref('campaign.archiveMetaStr') || JSON.stringify(JSON.parse(archiveStr), null, 4);
},
onSubmit(typ) {
// Validate custom JSON headers.
if (this.form.headersStr && this.form.headersStr !== '[]') {
try {
this.form.headers = JSON.parse(this.form.headersStr);
} catch (e) {
this.$utils.toast(e.toString(), 'is-danger');
return;
}
} else {
this.form.headers = [];
}
// Validate archive JSON body.
if (this.form.archive && this.form.archiveMetaStr) {
try {
this.form.archiveMeta = JSON.parse(this.form.archiveMetaStr);
} catch (e) {
this.$utils.toast(e.toString(), 'is-danger');
return;
}
}
// Validate custom JSON attribs.
let attribs = null;
if (this.form.attribsStr && this.form.attribsStr.trim()) {
try {
attribs = JSON.parse(this.form.attribsStr);
} catch (e) {
this.$utils.toast(
`${this.$t('subscribers.invalidJSON')}: ${e.toString()}`,
'is-danger',
3000,
);
return;
}
}
this.form.attribs = attribs;
switch (typ) {
case 'create':
this.createCampaign();
break;
case 'test':
this.sendTest();
break;
default:
this.updateCampaign();
break;
}
},
getCampaign(id) {
return this.$api.getCampaign(id).then((data) => {
this.data = data;
this.form = {
...this.form,
...data,
headersStr: JSON.stringify(data.headers, null, 4),
archiveMetaStr: data.archiveMeta ? JSON.stringify(data.archiveMeta, null, 4) : '{}',
attribsStr: data.attribs ? JSON.stringify(data.attribs, null, 4) : '{}',
// The structure that is populated by editor input event.
content: {
contentType: data.contentType,
body: data.body,
bodySource: data.bodySource,
templateId: data.templateId,
},
};
this.isAttachFieldVisible = this.form.media.length > 0;
this.form.media = this.form.media.map((f) => {
if (!f.id) {
return { ...f, filename: `${f.filename}` };
}
return f;
});
});
},
sendTest() {
const data = {
id: this.data.id,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
messenger: this.form.messenger,
type: 'regular',
headers: this.form.headers,
tags: this.form.tags,
template_id: this.form.content.templateId,
content_type: this.form.content.contentType,
body: this.form.content.body,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
subscribers: this.form.testEmails,
media: this.form.media.map((m) => m.id),
};
this.$api.testCampaign(data).then(() => {
this.$utils.toast(this.$t('campaigns.testSent'));
});
return false;
},
createCampaign() {
const data = {
archiveSlug: this.form.subject,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
content_type: this.form.content.contentType,
messenger: this.form.messenger,
type: 'regular',
tags: this.form.tags,
send_at: this.form.sendLater ? this.form.sendAtDate : null,
headers: this.form.headers,
attribs: this.form.attribs,
media: this.form.media.map((m) => m.id),
};
this.$api.createCampaign(data).then((d) => {
this.$router.push({ name: 'campaign', hash: '#content', params: { id: d.id } });
});
return false;
},
async updateCampaign(typ) {
const data = {
archive_slug: this.form.archiveSlug,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
messenger: this.form.messenger,
type: 'regular',
tags: this.form.tags,
send_at: this.form.sendLater ? this.form.sendAtDate : null,
headers: this.form.headers,
attribs: this.form.attribs,
template_id: this.form.content.templateId,
content_type: this.form.content.contentType,
body: this.form.content.body,
body_source: this.form.content.bodySource,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
archive: this.form.archive,
archive_template_id: this.form.archiveTemplateId,
archive_meta: this.form.archiveMeta,
media: this.form.media.map((m) => m.id),
};
let typMsg = 'globals.messages.updated';
if (typ === 'start') {
typMsg = 'campaigns.started';
}
if (!this.form.sendAtDate) {
this.form.sendLater = false;
}
// This promise is used by startCampaign to first save before starting.
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d;
this.form.archiveSlug = d.archiveSlug;
this.form.attribsStr = d.attribs ? JSON.stringify(d.attribs, null, 4) : '{}';
this.$utils.toast(this.$t(typMsg, { name: d.name }));
resolve();
});
});
},
onUpdateCampaignArchive() {
if (this.isEditing && this.canEdit) {
return;
}
const data = {
archive: this.form.archive,
archive_template_id: this.form.archiveTemplateId,
archive_meta: JSON.parse(this.form.archiveMetaStr),
archive_slug: this.form.archiveSlug,
};
this.$api.updateCampaignArchive(this.data.id, data).then((d) => {
this.form.archiveSlug = d.archiveSlug;
});
},
// Starts or schedule a campaign.
startCampaign() {
if (!this.canStart && !this.canSchedule) {
return;
}
this.$utils.confirm(
null,
() => {
// First save the campaign.
this.updateCampaign().then(() => {
// Then start/schedule it.
let status = '';
if (this.canStart) {
status = 'running';
} else if (this.canSchedule) {
status = 'scheduled';
} else {
return;
}
this.$api.changeCampaignStatus(this.data.id, status).then(() => {
this.$router.push({ name: 'campaigns' });
});
});
},
);
},
unscheduleCampaign() {
this.$api.changeCampaignStatus(this.data.id, 'draft').then((d) => {
this.data = d;
});
},
},
computed: {
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
canManage() {
return this.$can('campaigns:manage_all', 'campaigns:manage');
},
canSend() {
return this.$can('campaigns:send');
},
canEdit() {
return this.isNew
|| this.data.status === 'draft' || this.data.status === 'scheduled' || this.data.status === 'paused';
},
canSchedule() {
return (this.data.status === 'draft' || this.data.status === 'paused') && (this.form.sendLater && this.form.sendAtDate);
},
canUnSchedule() {
return this.data.status === 'scheduled';
},
canStart() {
return (this.data.status === 'draft' || this.data.status === 'paused') && !this.form.sendLater;
},
canArchive() {
return this.data.status !== 'cancelled' && this.data.type !== 'optin';
},
selectedLists() {
if (this.selListIDs.length === 0 || !this.lists.results) {
return [];
}
return this.lists.results.filter((l) => this.selListIDs.indexOf(l.id) > -1);
},
emailMessengers() {
return ['email', ...this.serverConfig.messengers.filter((m) => m.startsWith('email-'))];
},
otherMessengers() {
return this.serverConfig.messengers.filter((m) => m !== 'email' && !m.startsWith('email-'));
},
},
beforeRouteLeave(to, from, next) {
if (this.isUnsaved()) {
this.$utils.confirm(this.$t('globals.messages.confirmDiscard'), () => next(true));
return;
}
next(true);
},
watch: {
selectedLists() {
this.form.lists = this.selectedLists;
},
// eslint-disable-next-line func-names
'data.sendAt': function () {
if (this.data.sendAt !== null) {
this.form.sendLater = true;
this.form.sendAtDate = dayjs(this.data.sendAt).toDate();
} else {
this.form.sendLater = false;
this.form.sendAtDate = null;
}
},
},
mounted() {
window.onbeforeunload = () => this.isUnsaved() || null;
// Fill default form fields.
this.form.fromEmail = this.serverConfig.from_email;
// New campaign.
const { id } = this.$route.params;
if (id === 'new') {
this.isNew = true;
if (this.$route.query.list_id) {
// Multiple list_id query params.
let strIds = [];
if (typeof this.$route.query.list_id === 'object') {
strIds = this.$route.query.list_id;
} else {
strIds = [this.$route.query.list_id];
}
this.selListIDs = strIds.map((v) => parseInt(v, 10));
}
} else {
const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) {
this.$utils.toast(this.$t('campaigns.invalid'));
return;
}
this.isEditing = true;
}
// Get templates list.
this.$api.getTemplates().then((data) => {
if (data.length > 0) {
if (!this.form.templateId) {
const tpl = data.find((i) => i.isDefault === true);
this.form.templateId = tpl.id;
}
}
});
// Fetch campaign.
if (this.isEditing) {
this.getCampaign(id).then(() => {
if (this.$route.hash !== '') {
this.activeTab = this.$route.hash.replace('#', '');
}
});
} else {
this.form.messenger = 'email';
}
this.$nextTick(() => {
this.$refs.focus.focus();
});
this.$events.$on('campaign.update', () => {
this.onSubmit('update');
});
},
beforeDestroy() {
this.$events.$off('campaign.update');
},
});
</script>
+339
View File
@@ -0,0 +1,339 @@
<template>
<section class="analytics content relative">
<h1 class="title is-4">
{{ $t('analytics.title') }}
</h1>
<div v-if="serverConfig.privacy.disable_tracking || !serverConfig.privacy.individual_tracking"
class="notification is-info">
<template v-if="serverConfig.privacy.disable_tracking">
{{ $t('analytics.trackingDisabled') }}
</template>
<template v-else-if="!serverConfig.privacy.individual_tracking">
{{ $t('analytics.nonIndividualTracking') }}
</template>
</div>
<hr />
<form @submit.prevent="onSubmit">
<div class="columns">
<div class="column is-6">
<b-field :label="$t('globals.terms.campaigns')" label-position="on-border">
<b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.campaigns')" autocomplete :allow-new="false" :open-on-focus="true"
:before-adding="isCampaignSelected" @typing="queryCampaigns" @focus="queryCampaigns" field="name"
:loading="isSearchLoading" />
</b-field>
</div>
<div class="column is-5">
<div class="columns">
<div class="column is-6">
<b-field data-cy="from" :label="$t('analytics.fromDate')" label-position="on-border">
<b-datetimepicker v-model="form.from" icon="calendar-clock" :timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onFromDateChange" />
</b-field>
</div>
<div class="column is-6">
<b-field data-cy="to" :label="$t('analytics.toDate')" label-position="on-border">
<b-datetimepicker v-model="form.to" icon="calendar-clock" :timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onToDateChange" />
</b-field>
</div>
</div><!-- columns -->
</div><!-- columns -->
<div class="column is-1">
<b-button native-type="submit" type="is-primary" icon-left="magnify" :disabled="form.campaigns.length === 0"
data-cy="btn-search" />
</div>
</div><!-- columns -->
</form>
<section class="charts mt-5">
<div class="chart" v-for="(v, k) in charts" :key="k">
<div class="columns">
<div class="column is-9">
<b-loading v-if="v.loading" :active="v.loading" :is-full-page="false" />
<h4>
{{ v.name }}
<span v-if="v.type !== 'bar'" class="has-text-grey-light">({{ $utils.niceNumber(counts[k]) }})</span>
</h4>
<chart :type="v.type" v-if="!v.loading" :data="v.data" :on-click="v.onClick" />
</div>
<div class="column is-2 donut-container">
<chart type="donut" v-if="!v.loading" :data="v.donutData" />
</div>
</div>
</div>
</section>
</section>
</template>
<script>
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import { colors } from '../constants';
import Chart from '../components/Chart.vue';
const chartColorRed = '#ee7d5b';
const chartColors = [
colors.primary,
'#FFB50D',
'#41AC9C',
chartColorRed,
'#7FC7BC',
'#3a82d6',
'#688ED9',
'#FFC43D',
];
export default Vue.extend({
components: {
Chart,
},
data() {
return {
isSearchLoading: false,
queriedCampaigns: [],
// Data for each view.
counts: {
views: 0,
clicks: 0,
bounces: 0,
links: 0,
},
urls: [],
charts: {
views: {
name: this.$t('campaigns.views'),
type: 'line',
data: null,
fn: this.$api.getCampaignViewCounts,
chartFn: this.makeCharts,
loading: false,
},
clicks: {
name: this.$t('campaigns.clicks'),
type: 'line',
data: null,
fn: this.$api.getCampaignClickCounts,
chartFn: this.makeCharts,
loading: false,
},
bounces: {
name: this.$t('globals.terms.bounces'),
type: 'line',
data: null,
fn: this.$api.getCampaignBounceCounts,
chartFn: this.makeCharts,
donutColor: chartColorRed,
loading: false,
},
links: {
name: this.$t('analytics.links'),
type: 'bar',
data: null,
loading: false,
fn: this.$api.getCampaignLinkCounts,
chartFn: this.makeLinksChart,
onClick: this.onLinkClick,
},
},
form: {
campaigns: [],
from: null,
to: null,
},
};
},
methods: {
onFromDateChange() {
if (this.form.from > this.form.to) {
this.form.to = dayjs(this.form.from).add(7, 'day').toDate();
}
},
onToDateChange() {
if (this.form.from > this.form.to) {
this.form.from = dayjs(this.form.to).add(-7, 'day').toDate();
}
},
formatDateTime(s) {
return dayjs(s).format('YYYY-MM-DD HH:mm');
},
isCampaignSelected(camp) {
return !this.form.campaigns.find(({ id }) => id === camp.id);
},
makeLinksChart(typ, camps, data) {
const labels = data.map((l) => {
try {
this.urls.push(l.url);
const u = new URL(l.url);
if (l.url.length > 80) {
return `${u.hostname}${u.pathname.substr(0, 50)}..`;
}
return u.hostname + u.pathname;
} catch {
return l.url;
}
});
const out = {
labels,
datasets: [
{
data: data.map((l) => l.count),
backgroundColor: chartColors,
}],
};
return { points: out, donut: null };
},
makeCharts(typ, campaigns, data) {
// Make a campaign id => camp lookup map to group incoming
// data by campaigns.
const camps = campaigns.reduce((obj, c) => {
const out = { ...obj };
out[c.id] = c;
return out;
}, {});
const campIDs = Object.keys(camps);
// datasets[] array for line chart.
const lines = campIDs.map((id, n) => {
const cId = parseInt(id, 10);
const points = data.filter((item) => item.campaignId === cId);
return {
label: camps[id].name,
data: points.map((item) => ({ x: this.formatDateTime(item.timestamp), y: item.count })),
borderColor: chartColors[n % chartColors.length],
borderWidth: 2,
pointHoverBorderWidth: 5,
pointBorderWidth: 0.5,
};
});
// Donut.
const labels = [];
const points = campIDs.map((id) => {
labels.push(camps[id].name);
const cId = parseInt(id, 10);
const sum = data.reduce((a, item) => (item.campaignId === cId ? a + item.count : a), 0);
return sum;
});
const donut = {
labels,
datasets: [{
data: points, backgroundColor: chartColors, borderWidth: 6,
}],
};
return { points: { datasets: lines }, donut };
},
onSubmit() {
this.$router.push({ query: { id: this.form.campaigns.map((c) => c.id), from: dayjs(this.form.from).unix(), to: dayjs(this.form.to).unix() } });
},
queryCampaigns(q) {
this.isSearchLoading = true;
this.$api.getCampaigns({
query: q,
order_by: 'created_at',
order: 'DESC',
}).then((data) => {
this.isSearchLoading = false;
this.queriedCampaigns = data.results.map((c) => {
// Change the name to include the ID in the auto-suggest results.
const camp = c;
camp.name = `#${c.id}: ${c.name}`;
return camp;
});
});
},
getData(typ, camps) {
this.charts[typ].loading = true;
// Call the HTTP API.
this.charts[typ].fn({
id: camps.map((c) => c.id),
from: this.form.from,
to: this.form.to,
}).then((data) => {
// Set the total count.
this.counts[typ] = data.reduce((sum, d) => sum + d.count, 0);
const { points, donut } = this.charts[typ].chartFn(typ, camps, data);
this.charts[typ].data = points;
this.charts[typ].donutData = donut;
this.charts[typ].loading = false;
});
},
onLinkClick(e) {
const bars = e.chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
if (bars.length > 0) {
window.open(this.urls[bars[0].index], '_blank', 'noopener noreferrer');
}
},
},
computed: {
...mapState(['serverConfig']),
},
created() {
const now = dayjs().set('hour', 23).set('minute', 59).set('seconds', 0);
const weekAgo = now.subtract(7, 'day').set('hour', 0).set('minute', 0);
const from = this.$route.query.from ? dayjs.unix(this.$route.query.from) : weekAgo;
const to = this.$route.query.to ? dayjs.unix(this.$route.query.to) : now;
this.form.from = from.toDate();
this.form.to = to.toDate();
},
mounted() {
// Fetch one or more campaigns if there are ?id params, wait for the fetches
// to finish, add them to the campaign selector and submit the form.
const ids = this.$utils.parseQueryIDs(this.$route.query.id);
if (ids.length > 0) {
this.isSearchLoading = true;
Promise.allSettled(ids.map((id) => this.$api.getCampaign(id))).then((data) => {
data.forEach((d) => {
if (d.status !== 'fulfilled') {
return;
}
const camp = d.value;
camp.name = `#${camp.id}: ${camp.name}`;
this.form.campaigns.push(camp);
});
this.$nextTick(() => {
this.isSearchLoading = false;
// Fetch count for each analytics type (views, counts, bounces);
Object.keys(this.charts).forEach((k) => {
this.charts[k].data = null;
this.charts[k].donutData = null;
// Fetch views, clicks, bounces for every campaign.
this.getData(k, this.form.campaigns);
});
});
});
}
},
});
</script>
+549
View File
@@ -0,0 +1,549 @@
<template>
<section class="campaigns">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.campaigns') }}
<span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('campaigns:manage')" expanded>
<b-button expanded :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" class="btn-new"
type="is-primary" icon-left="plus" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow"
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getCampaigns">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
:placeholder="$t('campaigns.queryPlaceholder')" icon="magnify" ref="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" />
</p>
</b-field>
</div>
</form>
</div>
</div>
<div class="actions" v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="deleteCampaigns" data-cy="btn-delete-campaigns">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<span class="a">
{{ $tc('globals.messages.numSelected', numSelectedCampaigns, { num: numSelectedCampaigns }) }}
<span v-if="!bulk.all && campaigns.total > campaigns.perPage">
&mdash;
<a href="#" @click.prevent="onSelectAll" data-cy="select-all-campaigns">
{{ $tc('globals.messages.selectAll', campaigns.total, { num: campaigns.total }) }}
</a>
</span>
</span>
</div>
</template>
<b-table-column v-slot="props" cell-class="status" field="status" :label="$t('globals.fields.status')" width="10%"
sortable :td-attrs="$utils.tdID" header-class="cy-status">
<div>
<p>
<router-link :to="{ name: 'campaign', params: { id: props.row.id } }">
<b-tag :class="props.row.status">
{{ $t(`campaigns.status.${props.row.status}`) }}
</b-tag>
<span class="spinner is-tiny" v-if="isRunning(props.row.id)">
<b-loading :is-full-page="false" active />
</span>
</router-link>
</p>
<p v-if="isSheduled(props.row)">
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
<span v-if="!isDone(props.row) && !isRunning(props.row)">
{{ $utils.duration(new Date(), props.row.sendAt, true) }}
<br />
</span>
{{ $utils.niceDate(props.row.sendAt, true) }}
</span>
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" width="25%" sortable
header-class="cy-name">
<div>
<p>
<b-tag v-if="props.row.type === 'optin'" class="is-small">
{{ $t('lists.optin') }}
</b-tag>
<router-link :to="{ name: 'campaign', params: { id: props.row.id } }">
{{ props.row.name }}
<copy-text :text="props.row.name" hide-text />
</router-link>
</p>
<p class="is-size-7 has-text-grey">
<copy-text :text="props.row.subject" />
</p>
<b-taglist>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">
{{ t }}
</b-tag>
</b-taglist>
</div>
</b-table-column>
<b-table-column v-slot="props" cell-class="lists" field="lists" :label="$t('globals.terms.lists')" width="15%">
<ul>
<li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{ name: 'subscribers_list', params: { listID: l.id } }">
{{ l.name }}
</router-link>
</li>
</ul>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('campaigns.timestamps')" width="19%" sortable
header-class="cy-timestamp">
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label for="#">{{ $t('globals.fields.createdAt') }}</label>
<span>{{ $utils.niceDate(props.row.createdAt, true) }}</span>
</p>
<p v-if="stats.startedAt">
<label for="#">{{ $t('campaigns.startedAt') }}</label>
<span>{{ $utils.niceDate(stats.startedAt, true) }}</span>
</p>
<p v-if="isDone(props.row)">
<label for="#">{{ $t('campaigns.ended') }}</label>
<span>{{ $utils.niceDate(stats.updatedAt, true) }}</span>
</p>
<p v-if="stats.startedAt && stats.updatedAt" class="is-capitalized">
<label for="#"><b-icon icon="alarm" size="is-small" /></label>
<span>{{ $utils.duration(stats.startedAt, stats.updatedAt) }}</span>
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="15%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label for="#">{{ $t('campaigns.views') }}</label>
<span>{{ $utils.formatNumber(props.row.views) }}</span>
</p>
<p>
<label for="#">{{ $t('campaigns.clicks') }}</label>
<span>{{ $utils.formatNumber(props.row.clicks) }}</span>
</p>
<p>
<label for="#">{{ $t('campaigns.sent') }}</label>
<span>
{{ $utils.formatNumber(stats.sent) }} /
{{ $utils.formatNumber(stats.toSend) }}
</span>
</p>
<p>
<label for="#">{{ $t('globals.terms.bounces') }}</label>
<span>
<router-link :to="{ name: 'bounces', query: { campaign_id: props.row.id } }">
{{ $utils.formatNumber(props.row.bounces) }}
</router-link>
</span>
</p>
<p v-if="stats.rate">
<label for="#"><b-icon icon="speedometer" size="is-small" /></label>
<span class="send-rate">
<b-tooltip
:label="`${stats.netRate} / ${$t('campaigns.rateMinuteShort')} @ ${$utils.duration(stats.startedAt, stats.updatedAt)}`"
type="is-dark">
{{ stats.rate.toFixed(0) }} / {{ $t('campaigns.rateMinuteShort') }}
</b-tooltip>
</span>
</p>
<p v-if="isRunning(props.row.id)">
<label for="#">
{{ $t('campaigns.progress') }}
<span class="spinner is-tiny">
<b-loading :is-full-page="false" active />
</span>
</label>
<span>
<b-progress :value="stats.sent / stats.toSend * 100" size="is-small" />
</span>
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
<div>
<!-- start / pause / resume / scheduled -->
<template v-if="$can('campaigns:send')">
<a v-if="canStart(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
data-cy="btn-start" :aria-label="$t('campaigns.start')">
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canPause(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
:aria-label="$t('campaigns.pause')">
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canResume(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
data-cy="btn-resume" :aria-label="$t('campaigns.send')">
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canSchedule(props.row)" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<!-- placeholder for finished campaigns -->
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
aria-label=" ">
<b-icon icon="rocket-launch-outline" size="is-small" />
</a>
<a v-if="canCancel(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a v-else href="#" data-disabled aria-label=" ">
<b-icon icon="cancel" size="is-small" />
</a>
</template>
<a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview"
:aria-label="$t('campaigns.preview')">
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="$can('campaigns:manage')" href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }),
},
(name) => cloneCampaign(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<router-link v-if="$can('campaigns:get_analytics')"
:to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
<b-icon icon="chart-bar" size="is-small" />
</b-tooltip>
</router-link>
<a v-if="$can('campaigns:manage')" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.campaigns">
<empty-placeholder />
</template>
</b-table>
<campaign-preview v-if="previewItem" type="campaign" :id="previewItem.id" :title="previewItem.name"
@close="closePreview" />
</section>
</template>
<script>
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
import CopyText from '../components/CopyText.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
components: {
CampaignPreview,
EmptyPlaceholder,
CopyText,
},
data() {
return {
previewItem: null,
queryParams: {
page: 1,
query: '',
orderBy: 'created_at',
order: 'desc',
},
pollID: null,
campaignStatsData: {},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
};
},
methods: {
// Campaign statuses.
canStart(c) {
return c.status === 'draft' && !c.sendAt;
},
canSchedule(c) {
return c.status === 'draft' && c.sendAt;
},
canPause(c) {
return c.status === 'running';
},
canCancel(c) {
return c.status === 'running' || c.status === 'paused';
},
canResume(c) {
return c.status === 'paused';
},
isSheduled(c) {
return c.status === 'scheduled' || c.sendAt !== null;
},
isDone(c) {
return c.status === 'finished' || c.status === 'cancelled';
},
isRunning(id) {
if (id in this.campaignStatsData) {
return true;
}
return false;
},
highlightedRow(data) {
if (data.status === 'running') {
return ['running'];
}
return '';
},
onPageChange(p) {
this.queryParams.page = p;
this.getCampaigns();
},
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getCampaigns();
},
// Campaign actions.
previewCampaign(c) {
this.previewItem = c;
},
closePreview() {
this.previewItem = null;
},
getCampaigns() {
this.$api.getCampaigns({
page: this.queryParams.page,
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
no_body: true,
});
},
// Stats returns the campaign object with stats (sent, toSend etc.)
// if there's live stats available for running campaigns. Otherwise,
// it returns the incoming campaign object that has the static stats
// values.
getCampaignStats(c) {
if (c.id in this.campaignStatsData) {
return this.campaignStatsData[c.id];
}
return c;
},
pollStats() {
// Clear any running status polls.
clearInterval(this.pollID);
// Poll for the status as long as the import is running.
this.pollID = setInterval(() => {
this.$api.getCampaignStats().then((data) => {
// Stop polling. No running campaigns.
if (data.length === 0) {
clearInterval(this.pollID);
// There were running campaigns and stats earlier. Clear them
// and refetch the campaigns list with up-to-date fields.
if (Object.keys(this.campaignStatsData).length > 0) {
this.getCampaigns();
this.campaignStatsData = {};
}
} else {
// Turn the list of campaigns [{id: 1, ...}, {id: 2, ...}] into
// a map indexed by the id: {1: {}, 2: {}}.
this.campaignStatsData = data.reduce((obj, cur) => ({ ...obj, [cur.id]: cur }), {});
}
}, () => {
clearInterval(this.pollID);
});
}, 1000);
},
changeCampaignStatus(c, status) {
this.$api.changeCampaignStatus(c.id, status).then(() => {
this.$utils.toast(this.$t('campaigns.statusChanged', { name: c.name, status }));
this.getCampaigns();
this.pollStats();
});
},
async cloneCampaign(name, c) {
// Fetch the template body from the server.
let body = '';
let bodySource = null;
await this.$api.getCampaign(c.id).then((data) => {
body = data.body;
bodySource = data.bodySource;
});
const now = this.$utils.getDate();
const sendLater = !!c.sendAt;
let sendAt = null;
if (sendLater) {
sendAt = dayjs(c.sendAt).isAfter(now) ? c.sendAt : now.add(7, 'day');
}
const data = {
name,
subject: c.subject,
lists: c.lists.map((l) => l.id),
type: c.type,
from_email: c.fromEmail,
content_type: c.contentType,
messenger: c.messenger,
tags: c.tags,
template_id: c.templateId,
body,
body_source: bodySource,
altbody: c.altbody,
headers: c.headers,
send_later: sendLater,
send_at: sendAt,
archive: c.archive,
archive_template_id: c.archiveTemplateId,
archive_meta: c.archiveMeta,
media: c.media.map((m) => m.id),
};
if (c.archive) {
data.archive_slug = `${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString().slice(-4)}`;
}
this.$api.createCampaign(data).then((d) => {
this.$router.push({ name: 'campaign', params: { id: d.id } });
});
},
deleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns();
this.$utils.toast(this.$t('globals.messages.deleted', { name: c.name }));
});
},
// Mark all campaigns in the query as selected.
onSelectAll() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.campaigns.total) {
this.bulk.all = false;
}
},
deleteCampaigns() {
const name = this.$tc('globals.terms.campaign', this.numSelectedCampaigns);
const fn = () => {
const params = {};
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete campaigns by IDs.
params.id = this.bulk.checked.map((c) => c.id);
} else {
// 'All' is selected, delete by query.
params.query = this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' ');
params.all = this.bulk.all;
}
this.$api.deleteCampaigns(params)
.then(() => {
this.getCampaigns();
this.$utils.toast(this.$tc(
'globals.messages.deletedCount',
this.numSelectedCampaigns,
{ num: this.numSelectedCampaigns, name },
));
});
};
this.$utils.confirm(this.$tc(
'globals.messages.confirmDelete',
this.numSelectedCampaigns,
{ num: this.numSelectedCampaigns, name: name.toLowerCase() },
), fn);
},
},
computed: {
...mapState(['campaigns', 'loading']),
numSelectedCampaigns() {
return this.bulk.all ? this.campaigns.total : this.bulk.checked.length;
},
},
created() {
this.$root.$on('page.refresh', this.getCampaigns);
},
mounted() {
this.getCampaigns();
this.pollStats();
},
destroyed() {
this.$root.$off('page.refresh', this.getCampaigns);
clearInterval(this.pollID);
},
});
</script>
+229
View File
@@ -0,0 +1,229 @@
<template>
<section class="dashboard content">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-5">
{{ $utils.niceDate(new Date()) }}
</h1>
</div>
</header>
<section class="counts wrap">
<div class="tile is-ancestor">
<div class="tile is-vertical is-12">
<div class="tile">
<div class="tile is-parent is-vertical relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification" data-cy="lists">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">
<b-icon icon="format-list-bulleted-square" />
{{ $utils.niceNumber(counts.lists.total) }}
</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.list', counts.lists.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no has-text-grey">
<li>
<label for="#">{{ $utils.niceNumber(counts.lists.public) }}</label>
{{ $t('lists.types.public') }}
</li>
<li>
<label for="#">{{ $utils.niceNumber(counts.lists.private) }}</label>
{{ $t('lists.types.private') }}
</li>
<li>
<label for="#">{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
{{ $t('lists.optins.single') }}
</li>
<li>
<label for="#">{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
{{ $t('lists.optins.double') }}
</li>
</ul>
</div>
</div>
</article><!-- lists -->
<article class="tile is-child notification" data-cy="campaigns">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">
<b-icon icon="rocket-launch-outline" />
{{ $utils.niceNumber(counts.campaigns.total) }}
</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.campaign', counts.campaigns.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no has-text-grey">
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
<label for="#" :data-cy="`campaigns-${status}`">{{ num }}</label>
{{ $t(`campaigns.status.${status}`) }}
<span v-if="status === 'running'" class="spinner is-tiny">
<b-loading :is-full-page="false" active />
</span>
</li>
</ul>
</div>
</div>
</article><!-- campaigns -->
</div><!-- block -->
<div class="tile is-parent relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification" data-cy="subscribers">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">
<b-icon icon="account-multiple" />
{{ $utils.niceNumber(counts.subscribers.total) }}
</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no has-text-grey">
<li>
<label for="#">{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
{{ $t('subscribers.status.blocklisted') }}
</li>
<li>
<label for="#">{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
{{ $t('dashboard.orphanSubs') }}
</li>
</ul>
</div><!-- subscriber breakdown -->
</div><!-- subscriber columns -->
<hr />
<div class="columns" data-cy="messages">
<div class="column is-12">
<p class="title">
<b-icon icon="email-outline" />
{{ $utils.niceNumber(counts.messages) }}
</p>
<p class="is-size-6 has-text-grey">
{{ $t('dashboard.messagesSent') }}
</p>
</div>
</div>
</article><!-- subscribers -->
</div>
</div>
<div class="tile is-parent relative">
<b-loading v-if="isChartsLoading" active :is-full-page="false" />
<article class="tile is-child notification charts">
<div class="columns">
<div class="column is-6">
<h3 class="title is-size-6">
{{ $t('dashboard.campaignViews') }}
</h3><br />
<chart type="line" v-if="campaignViews" :data="campaignViews" />
</div>
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">
{{ $t('dashboard.linkClicks') }}
</h3><br />
<chart type="line" v-if="campaignClicks" :data="campaignClicks" />
</div>
</div>
</article>
</div>
</div>
</div><!-- tile block -->
<p v-if="settings['app.cache_slow_queries']" class="has-text-grey">
*{{ $t('globals.messages.slowQueriesCached') }}
</p>
</section>
</section>
</template>
<script>
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import { colors } from '../constants';
import Chart from '../components/Chart.vue';
export default Vue.extend({
components: {
Chart,
},
data() {
return {
isChartsLoading: true,
isCountsLoading: true,
campaignViews: null,
campaignClicks: null,
counts: {
lists: {},
subscribers: {},
campaigns: {},
messages: 0,
},
};
},
methods: {
fetchData() {
this.isCountsLoading = true;
this.isChartsLoading = true;
this.$api.getDashboardCounts().then((data) => {
this.counts = data;
this.isCountsLoading = false;
});
this.$api.getDashboardCharts().then((data) => {
this.isChartsLoading = false;
this.campaignViews = this.makeChart(data.campaignViews);
this.campaignClicks = this.makeChart(data.linkClicks);
});
},
makeChart(data) {
if (data.length === 0) {
return {};
}
return {
labels: data.map((d) => dayjs(d.date).format('DD MMM')),
datasets: [
{
data: [...data.map((d) => d.count)],
borderColor: colors.primary,
borderWidth: 2,
pointHoverBorderWidth: 5,
pointBorderWidth: 0.5,
},
],
};
},
},
computed: {
...mapState(['settings']),
dayjs() {
return dayjs;
},
},
created() {
this.$root.$on('page.refresh', this.fetchData);
},
destroyed() {
this.$root.$off('page.refresh', this.fetchData);
},
mounted() {
this.fetchData();
},
});
</script>
+176
View File
@@ -0,0 +1,176 @@
<template>
<section class="forms content relative">
<h1 class="title is-4">
{{ $t('forms.title') }}
</h1>
<hr />
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
<p v-else-if="publicLists.length === 0">
{{ $t('forms.noPublicLists') }}
</p>
<div class="columns" v-else-if="publicLists.length > 0">
<div class="column is-4">
<h4>{{ $t('forms.publicLists') }}</h4>
<p>{{ $t('forms.selectHelp') }}</p>
<b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no" data-cy="lists">
<li v-for="(l, i) in publicLists" :key="l.id">
<b-checkbox v-model="checked" :native-value="i">
{{ l.name }}
</b-checkbox>
</li>
</ul>
<template v-if="serverConfig.public_subscription.enabled">
<hr />
<h4>{{ $t('forms.publicSubPage') }}</h4>
<p>
<a :href="`${serverConfig.root_url}/subscription/form`" target="_blank" rel="noopener noreferer"
data-cy="url">
{{ serverConfig.root_url }}/subscription/form
</a>
</p>
</template>
<hr />
<h4>{{ $t('forms.redirectURL') }}</h4>
<p class="is-size-7 has-text-grey">
{{ $t('forms.redirectURLHelp') }}
</p>
<ul v-if="redirectURLs.length > 0" class="no" data-cy="redirect-urls">
<li>
<b-radio v-model="selectedRedirectURL" native-value="">
{{ $t('globals.terms.none') }}
</b-radio>
</li>
<li v-for="url in redirectURLs" :key="url">
<b-radio v-model="selectedRedirectURL" :native-value="url">
{{ url }}
</b-radio>
</li>
</ul>
</div>
<div class="column" data-cy="form">
<h4>{{ $t('forms.formHTML') }}</h4>
<p>
{{ $t('forms.formHTMLHelp') }}
</p>
<code-editor lang="html" v-if="checked.length > 0" v-model="html" disabled />
</div>
</div><!-- columns -->
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CodeEditor from '../components/CodeEditor.vue';
export default Vue.extend({
name: 'ListForm',
components: {
'code-editor': CodeEditor,
},
data() {
return {
checked: [],
html: '',
selectedRedirectURL: '',
};
},
methods: {
escapeAttr(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
renderHTML() {
let h = `<form method="post" action="${this.serverConfig.root_url}/subscription/form" class="eaglecast-form">\n`
+ ' <div>\n'
+ ` <h3>${this.$t('public.sub')}</h3>\n`
+ ' <input type="hidden" name="nonce" />\n';
if (this.selectedRedirectURL) {
h += ` <input type="hidden" name="next" value="${this.escapeAttr(this.selectedRedirectURL)}" />\n`;
}
h += '\n'
+ ` <p><input type="email" name="email" required placeholder="${this.$t('subscribers.email')}" /></p>\n`
+ ` <p><input type="text" name="name" placeholder="${this.$t('public.subName')}" /></p>\n\n`;
this.checked.forEach((i) => {
const l = this.publicLists[parseInt(i, 10)];
h += ' <p>\n'
+ ` <input id="${l.uuid.substr(0, 5)}" type="checkbox" name="l" checked value="${l.uuid}" />\n`
+ ` <label for="${l.uuid.substr(0, 5)}">${l.name}</label>\n`;
if (l.description) {
h += ' <br />\n'
+ ` <span>${l.description}</span>\n`;
}
h += ' </p>\n';
});
// Captcha?
if (this.serverConfig.public_subscription.captcha_enabled) {
if (this.serverConfig.public_subscription.captcha_provider === 'altcha') {
h += '\n'
+ ` <altcha-widget challengeurl="${this.serverConfig.root_url}/api/public/captcha/altcha"></altcha-widget>\n`
+ ` <${'script'} type="module" src="${this.serverConfig.root_url}/public/static/altcha.umd.js" async defer></${'script'}>\n`;
} else if (this.serverConfig.public_subscription.captcha_provider === 'hcaptcha') {
h += '\n'
+ ` <div class="h-captcha" data-sitekey="${this.serverConfig.public_subscription.captcha_key}"></div>\n`
+ ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer></${'script'}>\n`;
}
}
h += '\n'
+ ` <input type="submit" value="${this.$t('public.sub')} " />\n`
+ ' </div>\n'
+ '</form>';
this.html = h;
},
},
computed: {
...mapState(['loading', 'lists', 'serverConfig']),
publicLists() {
if (!this.lists.results) {
return [];
}
return this.lists.results.filter((l) => l.type === 'public');
},
redirectURLs() {
const urls = this.serverConfig.public_subscription
? this.serverConfig.public_subscription.redirect_urls
: [];
return Array.isArray(urls) ? urls : [];
},
},
watch: {
checked() {
this.renderHTML();
},
selectedRedirectURL() {
this.renderHTML();
},
},
});
</script>
+372
View File
@@ -0,0 +1,372 @@
<template>
<section class="import">
<h1 class="title is-4">
{{ $t('import.title') }}
</h1>
<b-loading :active="isLoading" />
<section v-if="isFree()" class="wrap">
<form @submit.prevent="onUpload" class="box">
<div>
<div class="columns">
<div class="column">
<b-field :label="$t('import.mode')" :addons="false">
<div>
<b-radio v-model="form.mode" name="mode" native-value="subscribe" data-cy="check-subscribe">
{{ $t('import.subscribe') }}
</b-radio>
<br />
<b-radio v-model="form.mode" name="mode" native-value="blocklist" data-cy="check-blocklist">
{{ $t('import.blocklist') }}
</b-radio>
</div>
</b-field>
</div>
<div class="column">
<b-field :label="$t('globals.fields.status')" :addons="false">
<template v-if="form.mode === 'subscribe'">
<b-radio v-model="form.subStatus" name="subStatus" native-value="unconfirmed"
data-cy="check-unconfirmed">
{{ $t('subscribers.status.unconfirmed') }}
</b-radio>
<b-radio v-model="form.subStatus" name="subStatus" native-value="confirmed" data-cy="check-confirmed">
{{ $t('subscribers.status.confirmed') }}
</b-radio>
</template>
<b-radio v-else v-model="form.subStatus" name="subStatus" native-value="unsubscribed"
data-cy="check-unsubscribed">
{{ $t('subscribers.status.unsubscribed') }}
</b-radio>
</b-field>
</div>
<div class="column">
<b-field :label="$t('import.csvDelim')" :message="$t('import.csvDelimHelp')" class="delimiter">
<b-input v-model="form.delim" name="delim" placeholder="," maxlength="1" required />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-4">
<b-field v-if="form.mode === 'subscribe'" :label="$t('import.overwriteUserInfo')"
:message="$t('import.overwriteUserInfoHelp')">
<div>
<b-switch v-model="form.overwriteUserInfo" name="overwriteUserInfo" data-cy="overwrite-user-info" />
</div>
</b-field>
</div>
<div class="column">
<b-field v-if="form.mode === 'subscribe'" :label="$t('import.overwriteSubStatus')"
:message="$t('import.overwriteSubStatusHelp')">
<div>
<b-switch v-model="form.overwriteSubStatus" name="overwriteSubStatus"
data-cy="overwrite-sub-status" />
</div>
</b-field>
</div>
</div>
<list-selector v-if="form.mode === 'subscribe'" :label="$t('globals.terms.lists')"
:placeholder="$t('import.listSubHelp')" :message="$t('import.listSubHelp')" v-model="form.lists"
:selected="form.lists" :all="lists.results" />
<hr />
<b-field :label="$t('import.csvFile')" label-position="on-border">
<b-upload v-model="form.file" drag-drop expanded>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large" />
</p>
<p>{{ $t('import.csvFileHelp') }}</p>
</div>
</b-upload>
</b-field>
<div class="tags" v-if="form.file">
<b-tag size="is-medium" closable @close="clearFile">
{{ form.file.name }}
</b-tag>
</div>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
:disabled="!form.file || (form.mode === 'subscribe' && form.lists.length === 0)" :loading="isProcessing">
{{ $t('import.upload') }}
</b-button>
</div>
</div>
</form>
<br /><br />
<div class="import-help">
<h5 class="title is-size-6">
{{ $t('import.instructions') }}
</h5>
<p>{{ $t('import.instructionsHelp') }}</p>
<br />
<blockquote class="csv-example">
<code class="csv-headers"> <span>email,</span> <span>name,</span> <span>attributes</span></code>
</blockquote>
<hr />
<h5 class="title is-size-6">
{{ $t('import.csvExample') }}
</h5>
<pre class="csv-example" v-text="example" />
</div>
</section><!-- upload //-->
<section v-if="isRunning() || isDone()" class="wrap status box has-text-centered">
<b-progress :value="progress" show-value type="is-success" />
<br />
<p
:class="['is-size-5', 'is-capitalized', { 'has-text-success': status.status === 'finished' }, { 'has-text-danger': (status.status === 'failed' || status.status === 'stopped') }]">
{{ status.status }}
</p>
<p>{{ $t('import.recordsCount', { num: status.imported, total: status.total }) }}</p>
<br />
<p>
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline" type="is-primary">
{{ isDone() ? $t('import.importDone') : $t('import.stopImport') }}
</b-button>
</p>
<br />
<div class="import-logs">
<log-view :lines="logs" :loading="false" />
</div>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
import LogView from '../components/LogView.vue';
export default Vue.extend({
components: {
ListSelector,
LogView,
},
props: {
data: { type: Object, default: () => { } },
isEditing: { type: Boolean, default: false },
},
data() {
return {
form: {
mode: 'subscribe',
subStatus: 'unconfirmed',
delim: ',',
lists: [],
overwriteUserInfo: false,
overwriteSubStatus: false,
file: null,
example: '',
},
// Initial page load still has to wait for the status API to return
// to either show the form or the status box.
isLoading: true,
isProcessing: false,
status: { status: '' },
logs: [],
pollID: null,
};
},
watch: {
'form.mode': function formMode() {
// Select the appropriate status radio whenever mode changes.
this.$nextTick(() => {
if (this.form.mode === 'subscribe') {
this.form.subStatus = 'unconfirmed';
} else {
this.form.subStatus = 'unsubscribed';
}
});
},
},
methods: {
clearFile() {
this.form.file = null;
},
// Returns true if we're free to do an upload.
isFree() {
if (this.status.status === 'none') {
return true;
}
return false;
},
// Returns true if an import is running.
isRunning() {
if (this.status.status === 'importing'
|| this.status.status === 'stopping') {
return true;
}
return false;
},
isSuccessful() {
return this.status.status === 'finished';
},
isFailed() {
return (
this.status.status === 'stopped'
|| this.status.status === 'failed'
);
},
// Returns true if an import has finished (failed or successful).
isDone() {
if (this.status.status === 'finished'
|| this.status.status === 'stopped'
|| this.status.status === 'failed'
) {
return true;
}
return false;
},
pollStatus() {
// Clear any running status polls.
clearInterval(this.pollID);
// Poll for the status as long as the import is running.
this.pollID = setInterval(() => {
this.$api.getImportStatus().then((data) => {
this.isProcessing = false;
this.isLoading = false;
this.status = data;
this.getLogs();
if (!this.isRunning()) {
clearInterval(this.pollID);
}
}, () => {
this.isProcessing = false;
this.isLoading = false;
this.status = { status: 'none' };
clearInterval(this.pollID);
});
return true;
}, 250);
},
getLogs() {
this.$api.getImportLogs().then((data) => {
this.logs = data.split('\n').map((line) => line.replace(/\s+importer\.go:\d+:\s*/, ' *: '));
Vue.nextTick(() => {
// vue.$refs doesn't work as the logs textarea is rendered dynamically.
const ref = document.getElementById('import-log');
if (ref) {
ref.scrollTop = ref.scrollHeight;
}
});
});
},
// Cancel a running import or clears a finished import.
stopImport() {
this.isProcessing = true;
this.$api.stopImport().then(() => {
this.pollStatus();
this.form.file = null;
});
},
renderExample() {
const h = 'email,name,attributes\n'
+ 'user1@mail.com,"User One","{""age"": 42, ""planet"": ""Mars""}"\n'
+ 'user2@mail.com,"User Two","{""age"": 24, ""job"": ""Time Traveller""}"';
this.example = h;
},
resetForm() {
this.form.mode = 'subscribe';
this.form.overwriteUserInfo = false;
this.form.overwriteSubStatus = false;
this.form.file = null;
this.form.lists = [];
this.form.subStatus = 'unconfirmed';
this.form.delim = ',';
},
onUpload() {
if (this.form.mode === 'subscribe' && this.form.overwriteSubStatus) {
this.$utils.confirm(this.$t('import.subscribeWarning'), this.onSubmit, this.resetForm);
return;
}
this.onSubmit();
},
onSubmit() {
this.isProcessing = true;
// Prepare the upload payload.
const params = new FormData();
params.set('params', JSON.stringify({
mode: this.form.mode,
subscription_status: this.form.subStatus,
delim: this.form.delim,
lists: this.form.lists.map((l) => l.id),
overwrite_userinfo: this.form.overwriteUserInfo,
overwrite_subscription_status: this.form.overwriteSubStatus,
}));
params.set('file', this.form.file);
// Post.
this.$api.importSubscribers(params).then(() => {
// On file upload, show a confirmation.
this.$utils.toast(this.$t('import.importStarted'));
// Start polling status.
this.pollStatus();
}, () => {
this.isProcessing = false;
this.form.file = null;
});
},
},
computed: {
...mapState(['lists']),
// Import progress bar value.
progress() {
if (!this.status || !this.status.total > 0) {
return 0;
}
return Math.ceil((this.status.imported / this.status.total) * 100);
},
},
mounted() {
this.renderExample();
this.pollStatus();
const ids = this.$utils.parseQueryIDs(this.$route.query.list_id);
if (ids.length > 0 && this.lists.results) {
this.$nextTick(() => {
this.form.lists = this.lists.results.filter((l) => ids.indexOf(l.id) > -1);
});
}
},
});
</script>
+152
View File
@@ -0,0 +1,152 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
{{ $t('globals.fields.uuid') }}: <copy-text :text="data.uuid" />
</p>
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">
{{ $t(`lists.types.${data.type}`) }}
</b-tag>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('lists.newList') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required />
</b-field>
<b-field :label="$t('lists.type')" label-position="on-border" :message="$t('lists.typeHelp')">
<b-select v-model="form.type" name="type" :placeholder="$t('lists.typeHelp')" required expanded>
<option value="private">
{{ $t('lists.types.private') }}
</option>
<option value="public">
{{ $t('lists.types.public') }}
</option>
</b-select>
</b-field>
<b-field :label="$t('lists.optin')" label-position="on-border" :message="$t('lists.optinHelp')">
<b-select v-model="form.optin" name="optin" placeholder="Opt-in type" required expanded>
<option value="single">
{{ $t('lists.optins.single') }}
</option>
<option value="double">
{{ $t('lists.optins.double') }}
</option>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" />
</b-field>
<b-field :label="$t('globals.fields.description')" label-position="on-border">
<b-input :maxlength="2000" v-model="form.description" name="description" type="textarea"
:placeholder="$t('globals.fields.description')" />
</b-field>
<b-field :message="$t('lists.archivedHelp')" :label="$t('lists.archived')">
<b-switch v-model="isArchived" name="status" />
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('lists:manage_all') || $canList(data.id, 'list:manage')" native-type="submit"
type="is-primary" :loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'ListForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
},
data() {
return {
// Binds form input values.
form: {
name: '',
type: 'private',
optin: 'single',
status: 'active',
tags: [],
},
};
},
methods: {
onSubmit() {
if (this.isEditing) {
this.updateList();
return;
}
this.createList();
},
createList() {
this.$api.createList(this.form).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
});
},
updateList() {
this.$api.updateList({ id: this.data.id, ...this.form }).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
});
},
},
computed: {
...mapState(['loading', 'profile']),
isArchived: {
get() {
return this.form.status === 'archived';
},
set(v) {
this.form.status = v ? 'archived' : 'active';
},
},
},
mounted() {
this.form = { ...this.form, ...this.$props.data };
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>
+380
View File
@@ -0,0 +1,380 @@
<template>
<section class="lists">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4 mb-2">
{{ $t('globals.terms.lists') }}
<span v-if="queryParams.status === 'archived'" class="has-text-grey-light">/ {{ queryParams.status }} </span>
<span v-if="!isNaN(lists.total)">({{ lists.total }})</span>
</h1>
<div class="is-size-7">
<router-link v-if="queryParams.status !== 'archived'" :to="{ name: 'lists', query: { status: 'archived' } }">
{{ $t('globals.buttons.view') }} {{ $t('lists.archived').toLowerCase() }} &rarr;
</router-link>
<router-link v-else :to="{ name: 'lists' }">
{{ $t('globals.buttons.view') }} {{ $t('menu.allLists').toLowerCase() }} &rarr;
</router-link>
</div>
</div>
<div class="column has-text-right">
<b-field v-if="$can('lists:manage_all')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="lists.results" :loading="loading.listsFull" @check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked" hoverable default-sort="createdAt" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="lists.perPage"
:total="lists.total" checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getLists">
<b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</form>
</div>
</div>
<div class="actions" v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="deleteLists" data-cy="btn-delete-lists">
<b-icon icon="trash-can-outline" size="is-small" /> {{ $t('globals.buttons.delete') }}
</a>
<span class="a">
{{ $tc('globals.messages.numSelected', numSelectedLists, { num: numSelectedLists }) }}
<span v-if="!bulk.all && lists.total > lists.perPage">
&mdash;
<a href="#" @click.prevent="onSelectAll" data-cy="select-all-lists">
{{ $tc('globals.messages.selectAll', lists.total, { num: lists.total }) }}
</a>
</span>
</span>
</div>
</template>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable
width="25%" paginated backend-pagination pagination-position="both" :td-attrs="$utils.tdID"
@page-change="onPageChange">
<div>
<a :href="`/lists/${props.row.id}`" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-taglist>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">
{{ t }}
</b-tag>
</b-taglist>
</div>
</b-table-column>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" header-class="cy-type" sortable
width="15%">
<div class="tags">
<b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $t(`lists.types.${props.row.type}`) }}
</b-tag>
{{ ' ' }}
<b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ? 'account-check-outline' : 'account-off-outline'"
size="is-small" />
{{ ' ' }}
{{ $t(`lists.optins.${props.row.optin}`) }}
</b-tag>{{ ' ' }}
<a v-if="props.row.optin === 'double'" class="is-size-7 send-optin" href="#"
@click="$utils.confirm(null, () => createOptinCampaign(props.row))" data-cy="btn-send-optin-campaign">
<b-tooltip :label="$t('lists.sendOptinCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
{{ $t('lists.sendOptinCampaign') }}
</b-tooltip>
</a>
</div>
</b-table-column>
<b-table-column v-slot="props" field="subscriber_count" :label="$t('globals.terms.subscribers')"
header-class="cy-subscribers" numeric sortable centered>
<template v-if="$can('subscribers:get_all', 'subscribers:get')">
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ $utils.formatNumber(props.row.subscriberCount) }}
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
</router-link>
</template>
<template v-else>
{{ $utils.formatNumber(props.row.subscriberCount) }}
</template>
</b-table-column>
<b-table-column v-slot="props" field="subscriber_counts" header-class="cy-subscribers" width="10%">
<div class="fields stats">
<p v-for="(count, status) in filterStatuses(props.row)" :key="status">
<label for="#">{{ $tc(`subscribers.status.${status}`, count) }}</label>
<router-link :to="`/subscribers/lists/${props.row.id}?subscription_status=${status}`" :class="status">
{{ $utils.formatNumber(count) }}
</router-link>
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<router-link v-if="$can('campaigns:manage')" :to="`/campaigns/new?list_id=${props.row.id}`"
data-cy="btn-campaign">
<b-tooltip :label="$t('lists.sendCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a v-if="$can('lists:manage') || $canList(props.row.id, 'list:manage')" href="#"
@click.prevent="showEditForm(props.row)" data-cy="btn-edit" :aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<router-link v-if="$can('subscribers:import')" :to="{ name: 'import', query: { list_id: props.row.id } }"
data-cy="btn-import">
<b-tooltip :label="$t('import.title')" type="is-dark">
<b-icon icon="file-upload-outline" size="is-small" />
</b-tooltip>
</router-link>
<a v-if="$can('lists:manage') || $canList(props.row.id, 'list:manage')" href="#"
@click.prevent="deleteList(props.row)" data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.listsFull">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600" @close="onFormClose">
<list-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
<p v-if="settings['app.cache_slow_queries']" class="has-text-grey">
*{{ $t('globals.messages.slowQueriesCached') }}
</p>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import ListForm from './ListForm.vue';
export default Vue.extend({
components: {
ListForm,
EmptyPlaceholder,
},
data() {
return {
// Current list item being edited.
curItem: null,
isEditing: false,
isFormVisible: false,
lists: [],
queryParams: {
page: 1,
query: '',
orderBy: 'id',
order: 'asc',
status: this.$route.query.status || 'active',
},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
};
},
methods: {
onPageChange(p) {
this.queryParams.page = p;
this.getLists();
},
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getLists();
},
// Show the edit list form.
showEditForm(list) {
this.curItem = list;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new list form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.getLists();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'lists' });
}
},
filterStatuses(list) {
const out = { ...list.subscriberStatuses };
if (list.optin === 'single') {
delete out.unconfirmed;
delete out.confirmed;
}
return out;
},
getLists() {
this.$api.queryLists({
page: this.queryParams.page,
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
status: this.queryParams.status,
}).then((resp) => {
this.lists = resp;
});
// Also fetch the minimal lists for the global store that appears
// in dropdown menus on other pages like import and campaigns.
this.$api.getLists({ minimal: true, per_page: 'all', status: 'active' });
},
deleteList(list) {
this.$utils.confirm(
this.$t('lists.confirmDelete'),
() => {
this.$api.deleteList(list.id).then(() => {
this.getLists();
this.$utils.toast(this.$t('globals.messages.deleted', { name: list.name }));
});
},
);
},
// Mark all lists in the query as selected.
onSelectAll() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.lists.total) {
this.bulk.all = false;
}
},
deleteLists() {
const name = this.$tc('globals.terms.list', this.numSelectedCampaigns);
const fn = () => {
const params = {};
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete lists by IDs.
params.id = this.bulk.checked.map((l) => l.id);
} else {
// 'All' is selected, delete by query.
params.query = this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' ');
params.all = this.bulk.all;
}
this.$api.deleteLists(params)
.then(() => {
this.getLists();
this.$utils.toast(this.$tc(
'globals.messages.deletedCount',
this.numSelectedLists,
{ num: this.numSelectedLists, name },
));
});
};
this.$utils.confirm(this.$tc(
'globals.messages.confirmDelete',
this.numSelectedLists,
{ num: this.numSelectedLists, name: name.toLowerCase() },
), fn);
},
createOptinCampaign(list) {
const data = {
name: this.$t('lists.optinTo', { name: list.name }),
subject: this.$t('lists.confirmSub', { name: list.name }),
lists: [list.id],
from_email: this.settings['app.from_email'],
content_type: 'richtext',
messenger: 'email',
type: 'optin',
};
this.$api.createCampaign(data).then((d) => {
this.$router.push({ name: 'campaign', hash: '#content', params: { id: d.id } });
});
return false;
},
},
computed: {
...mapState(['loading', 'settings']),
numSelectedLists() {
return this.bulk.all ? this.lists.total : this.bulk.checked.length;
},
},
created() {
this.$root.$on('page.refresh', this.getLists);
},
destroyed() {
this.$root.$off('page.refresh', this.getLists);
},
mounted() {
if (this.$route.params.id) {
this.$api.getList(parseInt(this.$route.params.id, 10)).then((data) => {
this.showEditForm(data);
});
} else {
this.getLists();
}
},
});
</script>
+51
View File
@@ -0,0 +1,51 @@
<template>
<section class="logs content relative">
<h1 class="title is-4">
{{ $t('logs.title') }}
</h1>
<hr />
<log-view :loading="loading.logs" :lines="lines" />
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import LogView from '../components/LogView.vue';
export default Vue.extend({
components: {
LogView,
},
data() {
return {
lines: [],
pollId: null,
};
},
methods: {
getLogs() {
this.$api.getLogs().then((data) => {
this.lines = data;
});
},
},
computed: {
...mapState(['logs', 'loading']),
},
mounted() {
this.getLogs();
// Update the logs every 10 seconds.
this.pollId = setInterval(() => this.getLogs(), 10000);
},
destroyed() {
clearInterval(this.pollId);
},
});
</script>
+283
View File
@@ -0,0 +1,283 @@
<template>
<section class="maintenance wrap">
<h1 class="title is-4">
{{ $t('maintenance.title') }}
</h1>
<hr />
<p class="has-text-grey">
{{ $t('maintenance.help') }}
</p>
<br />
<div class="box">
<h4 class="is-size-4">
{{ $t('globals.terms.subscribers') }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data" :message="$t('maintenance.orphanHelp')">
<b-select v-model="subscriberType" expanded>
<option value="orphan">
{{ $t('dashboard.orphanSubs') }}
</option>
<option value="blocklisted">
{{ $t('subscribers.status.blocklisted') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-5" />
<div class="column">
<br />
<b-field>
<b-button class="is-primary" :loading="loading.maintenance" @click="deleteSubscribers" expanded>
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- subscribers -->
<div class="box mt-6">
<h4 class="is-size-4">
{{ $tc('globals.terms.subscriptions', 2) }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data">
<b-select v-model="subscriptionType" expanded>
<option value="optin">
{{ $t('maintenance.maintenance.unconfirmedOptins') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('maintenance.olderThan')">
<b-datepicker v-model="subscriptionDate" required expanded icon="calendar-clock"
:date-formatter="formatDateTime" />
</b-field>
</div>
<div class="column is-1" />
<div class="column">
<br />
<b-field>
<b-button class="is-primary" :loading="loading.maintenance" @click="deleteSubscriptions" expanded>
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- subscriptions -->
<div class="box mt-6">
<h4 class="is-size-4">
{{ $t('globals.terms.analytics') }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data">
<b-select v-model="analyticsType" expanded>
<option selected value="all">
{{ $t('globals.terms.all') }}
</option>
<option value="views">
{{ $t('dashboard.campaignViews') }}
</option>
<option value="clicks">
{{ $t('dashboard.linkClicks') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('maintenance.olderThan')">
<b-datepicker v-model="analyticsDate" required expanded icon="calendar-clock"
:date-formatter="formatDateTime" />
</b-field>
</div>
<div class="column is-1" />
<div class="column">
<br />
<b-field>
<b-button expanded class="is-primary" :loading="loading.maintenance" @click="deleteAnalytics">
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
<hr />
<h5 class="is-size-5">
{{ $t('subscribers.export') }}
</h5>
<br />
<div class="columns">
<div class="column is-4">
<b-field label="Data">
<b-select v-model="exportType" expanded>
<option value="views">
{{ $t('dashboard.campaignViews') }}
</option>
<option value="clicks">
{{ $t('dashboard.linkClicks') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('analytics.fromDate')">
<b-datepicker v-model="exportDate" required expanded icon="calendar-clock"
:date-formatter="formatDateTime" />
</b-field>
</div>
<div class="column is-1" />
<div class="column">
<br />
<b-field>
<b-button expanded class="is-primary" tag="a" icon-left="download"
:href="exportURL">
{{ $t('subscribers.export') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- analytics -->
<form @submit.prevent="onUpdateDBSettings" class="box mt-6">
<h4 class="is-size-4">
{{ $t('maintenance.database.title') }}
</h4><br />
<h5 class="is-size-5">Vacuum</h5>
<p class="has-text-grey is-size-7">
{{ $t('maintenance.database.vacuumHelp') }}
</p>
<br />
<div class="columns">
<div class="column is-2">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="dbSettings.vacuum" />
</b-field>
</div>
<div class="column is-4" :class="{ disabled: !dbSettings.vacuum }">
<b-field :label="$t('settings.maintenance.cron')">
<b-input v-model="dbSettings.vacuum_cron_interval" placeholder="0 2 * * *" :disabled="!dbSettings.vacuum"
pattern="((\*|[0-9,\-\/]+)\s+){4}(\*|[0-9,\-\/]+)" />
</b-field>
</div>
<div class="column is-3" />
<div class="column is-3">
<br />
<b-button type="is-primary" native-type="submit" :loading="loading.settings" expanded>
{{ $t('globals.buttons.save') }}
</b-button>
</div>
</div>
</form><!-- database -->
<b-loading :is-full-page="true" v-if="isLoading" active />
</section>
</template>
<script>
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
components: {
},
data() {
return {
isLoading: false,
subscriberType: 'orphan',
analyticsType: 'all',
subscriptionType: 'optin',
analyticsDate: dayjs().subtract(7, 'day').toDate(),
subscriptionDate: dayjs().subtract(7, 'day').toDate(),
exportType: 'views',
exportDate: dayjs().subtract(30, 'day').toDate(),
dbSettings: {
vacuum: false,
vacuum_cron_interval: '0 2 * * *',
},
};
},
mounted() {
this.loadDBSettings();
},
methods: {
formatDateTime(s) {
return dayjs(s).format('YYYY-MM-DD');
},
deleteSubscribers() {
this.$utils.confirm(
null,
() => {
this.$api.deleteGCSubscribers(this.subscriberType).then((data) => {
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscribers', 2), num: data.count },
));
});
},
);
},
deleteSubscriptions() {
this.$utils.confirm(
null,
() => {
this.$api.deleteGCSubscriptions(this.subscriptionDate).then((data) => {
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscriptions', 2), num: data.count },
));
});
},
);
},
deleteAnalytics() {
this.$utils.confirm(
null,
() => {
this.$api.deleteGCCampaignAnalytics(this.analyticsType, this.analyticsDate)
.then(() => {
this.$utils.toast(this.$t('globals.messages.done'));
});
},
);
},
loadDBSettings() {
this.$api.getSettings().then((data) => {
if (data['maintenance.db'] !== undefined) {
this.dbSettings = { ...data['maintenance.db'] };
}
});
},
async onUpdateDBSettings() {
this.isLoading = true;
const data = await this.$api.updateSettingsByKey('maintenance.db', this.dbSettings);
await this.$root.awaitRestart(data);
this.isLoading = false;
},
},
computed: {
...mapState(['loading']),
exportURL() {
const since = encodeURIComponent(dayjs(this.exportDate).toISOString());
return `/api/maintenance/analytics/${this.exportType}/export?since=${since}`;
},
},
});
</script>
+242
View File
@@ -0,0 +1,242 @@
<template>
<section class="media-files">
<h1 class="title is-4">
{{ $t('media.title') }}
<span v-if="media.results && media.results.length > 0">({{ media.results.length }})</span>
<span class="has-text-grey-light"> / {{ serverConfig.media_provider }}</span>
</h1>
<b-loading :active="isProcessing || loading.media" />
<section class="wrap gallery mt-6">
<div class="columns mb-4">
<div class="column">
<form @submit.prevent="onQueryMedia" class="search">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</div>
</form>
</div>
<div v-if="$can('media:manage')" class="column is-narrow">
<b-button @click="onToggleForm" icon-left="file-upload-outline" data-cy="btn-toggle-upload">
{{ $t('media.upload') }}
</b-button>
</div>
</div>
<b-collapse v-if="$can('media:manage')" v-model="showUploadForm" animation="">
<form @submit.prevent="onSubmit" class="mb-6" data-cy="upload">
<div>
<b-field :label="$t('media.upload')">
<b-upload v-model="form.files" drag-drop multiple xaccept=".png,.jpg,.jpeg,.gif,.svg" expanded>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large" />
</p>
<p>{{ $t('media.uploadHelp') }}</p>
</div>
</b-upload>
</b-field>
<div class="tags" v-if="form.files.length > 0">
<b-tag v-for="(f, i) in form.files" :key="i" size="is-medium" closable @close="removeUploadFile(i)">
{{ f.name }}
</b-tag>
</div>
<div class="buttons">
<b-button native-type="submit" type="is-primary" icon-left="file-upload-outline"
:disabled="form.files.length === 0" :loading="isProcessing">
{{ $tc('media.upload') }}
</b-button>
</div>
</div>
</form>
</b-collapse>
<!-- Pagination -->
<div v-if="media.total > media.perPage" class="pagination-wrapper mt-5">
<b-pagination :total="media.total" :current.sync="media.page" :per-page="media.perPage"
@change="onPageChange" />
</div>
<div v-if="loading.media" class="has-text-centered py-6">
<b-loading :active="loading.media" />
</div>
<div v-else-if="media.results && media.results.length > 0" class="grid">
<div v-for="item in media.results" :key="item.id" class="item">
<div class="thumb">
<a @click="(e) => onMediaSelect(item, e)" :href="item.url" target="_blank" rel="noopener noreferer"
class="thumb-link">
<div class="thumb-container">
<img v-if="item.thumbUrl" :src="item.thumbUrl" :title="item.filename" :alt="item.filename" />
<div v-else class="thumb-placeholder">
<span class="file-ext">
{{ item.filename.split(".").pop().toUpperCase() }}
</span>
</div>
</div>
</a>
<div class="actions">
<a href="#" @click.prevent="$utils.confirm(null, () => onDeleteMedia(item.id))" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')" class="delete-btn">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
</div>
<div class="info">
<p class="filename" :title="item.filename">{{ item.filename }}</p>
<p class="date">{{ $utils.niceDate(item.createdAt, false) }}</p>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!loading.media">
<empty-placeholder />
</div>
<!-- Pagination -->
<div v-if="media.total > media.perPage" class="pagination-wrapper mt-5">
<b-pagination :total="media.total" :current.sync="media.page" :per-page="media.perPage"
@change="onPageChange" />
</div>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
},
name: 'Media',
props: {
isModal: Boolean,
type: { type: String, default: '' },
},
data() {
return {
form: {
files: [],
},
toUpload: 0,
uploaded: 0,
showUploadForm: false,
queryParams: {
page: 1,
query: '',
},
};
},
methods: {
removeUploadFile(i) {
this.form.files.splice(i, 1);
},
getMedia() {
this.$api.getMedia({
page: this.queryParams.page,
query: this.queryParams.query,
});
},
onToggleForm() {
this.showUploadForm = !this.showUploadForm;
this.$utils.setPref('media.upload', this.showUploadForm);
},
onQueryMedia() {
this.queryParams.page = 1;
this.getMedia();
},
onMediaSelect(m, e) {
// If the component is open in the modal mode, close the modal and
// fire the selection event.
// Otherwise, do nothing and let the image open like a normal link.
if (this.isModal) {
e.preventDefault();
this.$emit('selected', m);
this.$parent.close();
}
},
onSubmit() {
this.toUpload = this.form.files.length;
// Upload N files with N requests.
for (let i = 0; i < this.toUpload; i += 1) {
const params = new FormData();
params.set('file', this.form.files[i]);
this.$api.uploadMedia(params).then(() => {
this.onUploaded();
}, () => {
this.onUploaded();
});
}
},
onDeleteMedia(id) {
this.$api.deleteMedia(id).then(() => {
this.getMedia();
});
},
onUploaded() {
this.uploaded += 1;
if (this.uploaded >= this.toUpload) {
this.toUpload = 0;
this.uploaded = 0;
this.form.files = [];
this.getMedia();
}
},
onPageChange(p) {
this.queryParams.page = p;
this.getMedia();
},
},
computed: {
...mapState(['loading', 'media', 'serverConfig']),
isProcessing() {
if (this.toUpload > 0 && this.uploaded < this.toUpload) {
return true;
}
return false;
},
},
created() {
this.$root.$on('page.refresh', this.getMedia);
},
destroyed() {
this.$root.$off('page.refresh', this.getMedia);
},
mounted() {
this.$api.getMedia();
if (this.$utils.getPref('media.upload')) {
this.showUploadForm = true;
}
},
});
</script>
+279
View File
@@ -0,0 +1,279 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
</p>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ type === 'user' ? $t('users.newUserRole') : $t('users.newListRole') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input autofocus :disabled="disabled" :maxlength="200" v-model="form.name" name="name" ref="focus"
required />
</b-field>
<div v-if="type === 'list'" class="box">
<h5>{{ $t('users.listPerms') }}</h5>
<div class="mb-5">
<div class="columns">
<div class="column is-9">
<b-select :placeholder="$tc('globals.terms.list')" v-model="form.curList" name="list"
:disabled="disabled || filteredLists.length < 1" expanded class="mb-3">
<template v-for="l in filteredLists">
<option :value="l.id" :key="l.id">
{{ l.name }}
</option>
</template>
</b-select>
</div>
<div class="column">
<b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>
{{ $t('globals.buttons.add') }}
</b-button>
</div>
</div>
<span
v-if="form.lists.length > 0 && (form.permissions['lists:get_all'] || form.permissions['lists:manage_all'])"
class="is-size-6 has-text-danger">
<b-icon icon="warning-empty" />
{{ $t('users.listPermsWarning') }}
</span>
</div>
<b-table :data="form.lists">
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list')">
<router-link :to="`/lists/${props.row.id}`" target="_blank">
{{ props.row.name }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="permissions" :label="$t('users.perms')" width="40%">
<b-checkbox v-model="props.row.permissions" native-value="list:get">
{{ $t('globals.buttons.view') }}
</b-checkbox>
<b-checkbox v-model="props.row.permissions" native-value="list:manage">
{{ $t('globals.buttons.manage') }}
</b-checkbox>
</b-table-column>
<b-table-column v-slot="props" width="10%">
<a href="#" @click.prevent="onDeleteListPerm(props.row.id)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</b-table>
</div>
<template v-if="type === 'user'">
<div class="columns">
<div class="column is-7">
<h5 class="mb-0">
{{ $t('users.perms') }}
</h5>
</div>
<div class="column has-text-right" v-if="!disabled">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
</div>
</div>
<b-table :data="serverConfig.permissions">
<b-table-column v-slot="props" field="group" :label="$t('users.roleGroup')">
{{ $tc(`globals.terms.${props.row.group}`) }}
</b-table-column>
<b-table-column v-slot="props" field="permissions" label="Permissions">
<div v-for="p in props.row.permissions" :key="p">
<b-checkbox v-model="form.permissions" :native-value="p" :disabled="disabled">
{{ p }}
<b-icon v-if="p === 'subscribers:sql_query'" icon="warning-empty" type="is-danger" size="is-small"
aria-label="Warning: high risk permission" />
</b-checkbox>
</div>
</b-table-column>
</b-table>
</template>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="!disabled" native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'RoleForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
type: { type: String, default: 'user' },
},
data() {
return {
// Binds form input values.
form: {
curList: null,
lists: [],
name: null,
permissions: {},
},
hasToggle: false,
disabled: false,
};
},
methods: {
onAddListPerm() {
const list = this.lists.results.find((l) => l.id === this.form.curList);
this.form.lists.push({ id: list.id, name: list.name, permissions: ['list:get', 'list:manage'] });
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onDeleteListPerm(id) {
this.form.lists = this.form.lists.filter((p) => p.id !== id);
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onSubmit() {
if (this.isEditing) {
this.updateRole();
return;
}
this.createRole();
},
onToggleSelect() {
if (this.hasToggle) {
this.form.permissions = [];
} else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => {
acc.push(p);
});
return acc;
}, []);
}
this.hasToggle = !this.hasToggle;
},
createRole() {
let fn;
const form = { name: this.form.name };
if (this.$props.type === 'user') {
fn = this.$api.createUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.createListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
this.$parent.close();
});
},
updateRole() {
let fn;
const form = { id: this.$props.data.id, name: this.form.name };
if (this.$props.type === 'user') {
fn = this.$api.updateUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.updateListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
this.$parent.close();
});
},
},
computed: {
...mapState(['loading', 'serverConfig', 'lists']),
// Return the list of unselected lists.
filteredLists() {
if (!this.lists.results || this.type !== 'list') {
return [];
}
const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
return this.lists.results.filter((l) => (!(l.id in subIDs)));
},
},
mounted() {
if (this.isEditing) {
this.form = { ...this.form, ...this.$props.data };
// It's the superadmin role. Disable the form.
if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
this.disabled = true;
}
} else {
const skip = ['admin', 'users'];
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
if (skip.includes(item.group)) {
return acc;
}
item.permissions.forEach((p) => {
if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
acc.push(p);
}
});
return acc;
}, []);
}
this.$nextTick(() => {
if (this.filteredLists.length > 0) {
this.form.curList = this.filteredLists[0].id;
}
this.$refs.focus.focus();
});
},
});
</script>
+193
View File
@@ -0,0 +1,193 @@
<template>
<section class="roles">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t(isUser ? 'users.userRoles' : 'users.listRoles') }}
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('users:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm('user')"
data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="roles" :loading="isLoading()" hoverable>
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
<a href="#" @click.prevent="showEditForm(props.row, 'user')">
<b-tag v-if="props.row.id === 1" class="enabled">
{{ props.row.name }}
</b-tag>
<template v-else>{{ props.row.name }}</template>
</a>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions has-text-right">
<template v-if="$can('roles:manage')">
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }),
},
(name) => onCloneRole(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<template v-if="props.row.id !== 1">
<a href="#" @click.prevent="showEditForm(props.row, 'user')" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="onDeleteRole(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</template>
</template>
</b-table-column>
<template #empty v-if="!isLoading()">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="700" @close="onFormClose">
<role-form :data="curItem" :type="curType" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import RoleForm from './RoleForm.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
RoleForm,
},
data() {
return {
curItem: null,
curType: null,
isEditing: false,
isFormVisible: false,
};
},
methods: {
isLoading() {
return this.curType === 'user' ? this.loading.userRoles : this.loading.listRoles;
},
fetchRoles() {
if (this.isUser) {
this.$api.getUserRoles();
} else {
this.$api.getListRoles();
}
},
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.curType = this.isUser ? 'user' : 'list';
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.isEditing = false;
this.isFormVisible = true;
},
formFinished() {
this.fetchRoles();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'users' });
}
},
onCloneRole(name, item) {
const form = { name };
let fn;
if (this.isUser) {
fn = this.$api.createUserRole;
form.permissions = item.permissions;
} else {
fn = this.$api.createListRole;
form.lists = item.lists;
}
fn(form).then(() => {
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.created', { name }));
});
},
onDeleteRole(item) {
this.$utils.confirm(
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteRole(item.id).then(() => {
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
},
);
},
},
computed: {
...mapState(['loading', 'userRoles', 'listRoles']),
isUser() {
return this.curType === 'user';
},
isList() {
return this.curType === 'list';
},
roles() {
return this.isUser ? this.userRoles : this.listRoles;
},
},
mounted() {
this.curType = this.$route.name === 'userRoles' ? 'user' : 'list';
this.fetchRoles();
},
});
</script>
+292
View File
@@ -0,0 +1,292 @@
<template>
<form @submit.prevent="onSubmit">
<section class="settings">
<b-loading :is-full-page="true" v-if="loading.settings || isLoading" active />
<header class="columns page-header">
<div class="column is-half">
<h1 class="title is-4">
{{ $t('settings.title') }}
<span class="has-text-grey-light">({{ serverConfig.version }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('settings:manage')" expanded>
<b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline"
native-type="submit" class="isSaveEnabled" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</b-field>
</div>
</header>
<hr />
<section class="wrap settings-wrap" v-if="form">
<b-tabs class="settings-tabs" vertical :animated="false" v-model="tab">
<b-tab-item :label="$t('settings.general.name')">
<general-settings :form="form" :key="key" />
</b-tab-item><!-- general -->
<b-tab-item :label="$t('settings.performance.name')">
<performance-settings :form="form" :key="key" />
</b-tab-item><!-- performance -->
<b-tab-item :label="$t('settings.privacy.name')">
<privacy-settings :form="form" :key="key" />
</b-tab-item><!-- privacy -->
<b-tab-item :label="$t('settings.security.name')">
<security-settings :form="form" :key="key" />
</b-tab-item><!-- security -->
<b-tab-item :label="$t('settings.media.title')">
<media-settings :form="form" :key="key" />
</b-tab-item><!-- media -->
<b-tab-item :label="$t('settings.smtp.name')">
<smtp-settings :form="form" :key="key" />
</b-tab-item><!-- mail servers -->
<b-tab-item :label="$t('settings.bounces.name')">
<bounce-settings :form="form" :key="key" />
</b-tab-item><!-- bounces -->
<b-tab-item :label="$t('settings.messengers.name')">
<messenger-settings :form="form" :key="key" />
</b-tab-item><!-- messengers -->
<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->
</b-tabs>
</section>
</section>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import AppearanceSettings from './settings/appearance.vue';
import BounceSettings from './settings/bounces.vue';
import GeneralSettings from './settings/general.vue';
import MediaSettings from './settings/media.vue';
import MessengerSettings from './settings/messengers.vue';
import PerformanceSettings from './settings/performance.vue';
import PrivacySettings from './settings/privacy.vue';
import SecuritySettings from './settings/security.vue';
import SmtpSettings from './settings/smtp.vue';
export default Vue.extend({
components: {
GeneralSettings,
PerformanceSettings,
PrivacySettings,
SecuritySettings,
MediaSettings,
SmtpSettings,
BounceSettings,
MessengerSettings,
AppearanceSettings,
},
data() {
return {
// :key="key" is a ack to re-render child components every time settings
// is pulled. Otherwise, props don't react.
key: 0,
isLoading: false,
// formCopy is a stringified copy of the original settings against which
// form is compared to detect changes.
formCopy: '',
form: null,
tab: 0,
};
},
methods: {
async onSubmit() {
const form = JSON.parse(JSON.stringify(this.form));
// SMTP boxes.
let hasDummy = '';
for (let i = 0; i < form.smtp.length; i += 1) {
// trim the host before saving
form.smtp[i].host = form.smtp[i].host?.trim();
// If it's the dummy UI password placeholder, ignore it.
if (this.isDummy(form.smtp[i].password)) {
form.smtp[i].password = '';
} else if (this.hasDummy(form.smtp[i].password)) {
hasDummy = `smtp #${i + 1}`;
}
if (form.smtp[i].strEmailHeaders && form.smtp[i].strEmailHeaders !== '[]') {
form.smtp[i].email_headers = JSON.parse(form.smtp[i].strEmailHeaders);
} else {
form.smtp[i].email_headers = [];
}
}
// Bounces boxes.
for (let i = 0; i < form['bounce.mailboxes'].length; i += 1) {
// trim the host before saving
form['bounce.mailboxes'][i].host = form['bounce.mailboxes'][i].host?.trim();
// If it's the dummy UI password placeholder, ignore it.
if (this.isDummy(form['bounce.mailboxes'][i].password)) {
form['bounce.mailboxes'][i].password = '';
} else if (this.hasDummy(form['bounce.mailboxes'][i].password)) {
hasDummy = `bounce #${i + 1}`;
}
}
if (this.isDummy(form['upload.s3.aws_secret_access_key'])) {
form['upload.s3.aws_secret_access_key'] = '';
} else if (this.hasDummy(form['upload.s3.aws_secret_access_key'])) {
hasDummy = 's3';
}
if (this.isDummy(form['bounce.sendgrid_key'])) {
form['bounce.sendgrid_key'] = '';
} else if (this.hasDummy(form['bounce.sendgrid_key'])) {
hasDummy = 'sendgrid';
}
if (this.isDummy(form['bounce.azure'].shared_secret)) {
form['bounce.azure'].shared_secret = '';
} else if (this.hasDummy(form['bounce.azure'].shared_secret)) {
hasDummy = 'azure shared secret';
}
if (this.isDummy(form['security.captcha'].hcaptcha.secret)) {
form['security.captcha'].hcaptcha.secret = '';
} else if (this.hasDummy(form['security.captcha'].hcaptcha.secret)) {
hasDummy = 'captcha';
}
if (this.isDummy(form['security.oidc'].client_secret)) {
form['security.oidc'].client_secret = '';
} else if (this.hasDummy(form['security.oidc'].client_secret)) {
hasDummy = 'oidc';
}
if (this.isDummy(form['bounce.postmark'].password)) {
form['bounce.postmark'].password = '';
} else if (this.hasDummy(form['bounce.postmark'].password)) {
hasDummy = 'postmark';
}
if (this.isDummy(form['bounce.forwardemail'].key)) {
form['bounce.forwardemail'].key = '';
} else if (this.hasDummy(form['bounce.forwardemail'].key)) {
hasDummy = 'forwardemail';
}
if (this.isDummy(form['bounce.lettermint'].key)) {
form['bounce.lettermint'].key = '';
} else if (this.hasDummy(form['bounce.lettermint'].key)) {
hasDummy = 'lettermint';
}
for (let i = 0; i < form.messengers.length; i += 1) {
// If it's the dummy UI password placeholder, ignore it.
if (this.isDummy(form.messengers[i].password)) {
form.messengers[i].password = '';
} else if (this.hasDummy(form.messengers[i].password)) {
hasDummy = `messenger #${i + 1}`;
}
}
if (hasDummy) {
this.$utils.toast(this.$t('globals.messages.passwordChangeFull', { name: hasDummy }), 'is-danger');
return false;
}
// Domain blocklist array from multi-line strings.
form['privacy.domain_blocklist'] = form['privacy.domain_blocklist'].split('\n').map((v) => v.trim().toLowerCase()).filter((v) => v !== '');
form['privacy.domain_allowlist'] = form['privacy.domain_allowlist'].split('\n').map((v) => v.trim().toLowerCase()).filter((v) => v !== '');
this.isLoading = true;
try {
const data = await this.$api.updateSettings(form);
await this.$root.awaitRestart(data);
this.getSettings();
} finally {
this.isLoading = false;
}
return false;
},
getSettings() {
this.isLoading = true;
this.$api.getSettings().then((data) => {
let d = {};
try {
// Create a deep-copy of the settings hierarchy.
d = JSON.parse(JSON.stringify(data));
} catch (err) {
return;
}
// Serialize the `email_headers` array map to display on the form.
for (let i = 0; i < d.smtp.length; i += 1) {
d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4);
}
// Domain blocklist array to multi-line string.
d['privacy.domain_blocklist'] = d['privacy.domain_blocklist'].join('\n');
d['privacy.domain_allowlist'] = d['privacy.domain_allowlist'].join('\n');
this.key += 1;
this.form = d;
this.formCopy = JSON.stringify(d);
this.$nextTick(() => {
this.isLoading = false;
});
});
},
isDummy(pwd) {
return !pwd || (pwd.match(/•/g) || []).length === pwd.length;
},
hasDummy(pwd) {
return pwd.includes('•');
},
},
computed: {
...mapState(['serverConfig', 'loading']),
hasFormChanged() {
if (!this.formCopy) {
return false;
}
return JSON.stringify(this.form) !== this.formCopy;
},
},
beforeRouteLeave(to, from, next) {
if (this.hasFormChanged) {
this.$utils.confirm(this.$t('globals.messages.confirmDiscard'), () => next(true));
return;
}
next(true);
},
mounted() {
this.tab = this.$utils.getPref('settings.tab') || 0;
this.getSettings();
},
watch: {
tab(t) {
this.$utils.setPref('settings.tab', t);
},
},
});
</script>
+87
View File
@@ -0,0 +1,87 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4 class="title is-size-5">
{{ $t('subscribers.manageLists') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Action">
<div>
<b-radio v-model="form.action" name="action" native-value="add" data-cy="check-list-add">
{{ $t('globals.buttons.add') }}
</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove" data-cy="check-list-remove">
{{ $t('globals.buttons.remove') }}
</b-radio>
<b-radio v-model="form.action" name="action" native-value="unsubscribe" data-cy="check-list-unsubscribe">
{{ $t('subscribers.markUnsubscribed') }}
</b-radio>
</div>
</b-field>
<list-selector label="Target lists" placeholder="Lists to apply to" v-model="form.lists" :selected="form.lists"
:all="lists.results" />
<b-field :message="$t('subscribers.preconfirmHelp')">
<b-checkbox v-model="form.preconfirm" data-cy="preconfirm" :native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :disabled="form.lists.length === 0">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
export default Vue.extend({
components: {
ListSelector,
},
props: {
numSubscribers: { type: Number, default: 0 },
},
data() {
return {
// Binds form input values.
form: {
action: 'add',
lists: [],
preconfirm: false,
},
};
},
methods: {
onSubmit() {
this.$emit('finished', this.form.action, this.form.preconfirm, this.form.lists);
this.$parent.close();
},
},
computed: {
...mapState(['lists', 'loading']),
hasOptinList() {
return this.form.lists.some((l) => l.optin === 'double');
},
},
});
</script>
+359
View File
@@ -0,0 +1,359 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">
{{ $t(`subscribers.status.${data.status}`) }}
</b-tag>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('subscribers.newSubscriber') }}
</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: <span data-cy="id"><copy-text :text="`${data.id}`" /></span>
{{ $t('globals.fields.uuid') }}: <copy-text :text="data.uuid" />
</p>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :ref="'focus'"
:placeholder="$t('subscribers.email')" required />
</b-field>
<div class="columns">
<div class="column is-8">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('globals.fields.status')" label-position="on-border"
:message="$t('subscribers.blocklistedHelp')">
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required
expanded>
<option value="enabled">
{{ $t('subscribers.status.enabled') }}
</option>
<option value="blocklisted">
{{ $t('subscribers.status.blocklisted') }}
</option>
</b-select>
</b-field>
</div>
</div>
<b-tabs type="is-boxed" :animated="false">
<b-tab-item :label="$t('globals.terms.lists')" label-position="on-border">
<list-selector :label="$t('subscribers.lists')" :placeholder="$t('subscribers.listsPlaceholder')"
:message="$t('subscribers.listsHelp')" v-model="form.lists" :selected="form.lists" :all="lists.results" />
<div class="columns">
<div class="column is-7">
<b-field :message="$t('subscribers.preconfirmHelp')">
<b-checkbox v-model="form.preconfirm" :native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
</b-field>
</div>
<div v-if="$can('subscribers:manage') && isEditing" class="column is-5 has-text-right">
<a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }">
<b-icon icon="email-outline" size="is-small" />
{{ $t('subscribers.sendOptinConfirm') }}</a>
</div>
</div>
</b-tab-item><!-- lists -->
<b-tab-item :label="`${$tc('globals.terms.subscriptions', 2)} (${data.lists ? data.lists.length : 0})`"
label-position="on-border" :disabled="!data.lists || data.lists.length === 0">
<template v-if="data.lists">
<b-table :data="data.lists" hoverable default-sort="createdAt" class="subscriptions">
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list', 1)">
<div>
<router-link v-if="!props.row.restricted" :to="`/lists/${props.row.id}`">
{{ props.row.name }}
</router-link>
<span v-else class="has-text-grey-light is-italic">{{ props.row.name }}</span>
<br />
<b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ? 'account-check-outline' : 'account-off-outline'"
size="is-small" />
{{ ' ' }}
{{ $t(`lists.optins.${props.row.optin}`) }}
</b-tag>{{ ' ' }}
</div>
</b-table-column>
<b-table-column v-slot="props" field="status" cell-class="status" :label="$t('globals.fields.status')">
<b-tag :class="`status-${props.row.subscriptionStatus}`">
{{ $t(`subscribers.status.${props.row.subscriptionStatus}`) }}
</b-tag>
<template v-if="props.row.optin === 'double' && props.row.subscriptionMeta.optinIp">
<br /><span class="is-size-7">{{ props.row.subscriptionMeta.optinIp }}</span>
</template>
</b-table-column>
<b-table-column v-slot="props" field="createdAt" :label="$t('globals.fields.createdAt')">
{{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
</b-table-column>
<b-table-column v-slot="props" field="updatedAt" :label="$t('globals.fields.updatedAt')">
{{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
</b-table-column>
</b-table>
</template>
</b-tab-item><!-- subscriptions -->
<b-tab-item :label="`${$t('globals.terms.bounces')} (${bounces.length})`" class="bounces"
:disabled="bounces.length === 0">
<a href="#" class="is-size-6 is-pulled-right" disabed="true" @click.prevent="deleteBounces"
v-if="isBounceVisible">
<b-icon icon="trash-can-outline" />
{{ $t('globals.buttons.delete') }}
</a>
<b-table :data="bounces" hoverable default-sort="createdAt" class="bounces">
<b-table-column field="campaign" :label="$tc('globals.terms.campaign', 1)" v-slot="props">
<div v-if="props.row.campaign">
<router-link :to="{ name: 'bounces', query: { campaign_id: props.row.campaign.id } }">
{{ props.row.campaign.name }}
</router-link>
</div>
</b-table-column>
<b-table-column field="createdAt" :label="$t('globals.fields.createdAt')" v-slot="props">
{{ $utils.niceDate(props.row.createdAt, true) }}
</b-table-column>
<b-table-column field="action" :label="$t('globals.fields.type')" v-slot="props">
<span class="is-pulled-right">
<a href="#" @click.prevent="toggleMeta(props.row.id)">
{{ props.row.source }}
<b-icon :icon="visibleMeta[props.row.id] ? 'arrow-up' : 'arrow-down'" />
</a>
</span>
<span class="is-clearfix" />
<pre v-if="visibleMeta[props.row.id]">{{ props.row.meta }}</pre>
</b-table-column>
</b-table>
</b-tab-item><!-- bounces -->
<b-tab-item :label="$t('subscribers.activity')" class="activity" :disabled="!isEditing">
<subscriber-activity v-if="isEditing && data.id" :subscriber-id="data.id" />
</b-tab-item><!-- activity -->
</b-tabs>
<b-field :message="$t('subscribers.attribsHelp') + ' ' + egAttribs" class="mt-6">
<div>
<h5>{{ $t('globals.terms.attribs') }}</h5>
<b-input v-model="form.strAttribs" name="attribs" type="textarea" />
</div>
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('subscribers:manage')" native-type="submit" type="is-primary"
:loading="loading.subscribers">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
import CopyText from '../components/CopyText.vue';
import SubscriberActivity from '../components/SubscriberActivity.vue';
export default Vue.extend({
components: {
ListSelector,
CopyText,
SubscriberActivity,
},
props: {
data: {
type: Object,
default: () => ({ lists: [] }),
},
isEditing: Boolean,
},
data() {
return {
// Binds form input values. This is populated by subscriber props passed
// from the parent component in mounted().
form: {
lists: [],
strAttribs: '{}',
status: 'enabled',
preconfirm: false,
},
isBounceVisible: false,
bounces: [],
visibleMeta: {},
egAttribs: '{"job": "developer", "location": "Mars", "has_rocket": true}',
};
},
methods: {
toggleBounces() {
this.isBounceVisible = !this.isBounceVisible;
},
toggleMeta(id) {
let v = false;
if (!this.visibleMeta[id]) {
v = true;
}
Vue.set(this.visibleMeta, id, v);
},
deleteBounces(sub) {
this.$utils.confirm(
null,
() => {
this.$api.deleteSubscriberBounces(this.form.id).then(() => {
this.getBounces();
this.$utils.toast(this.$t('globals.messages.deleted', { name: sub.name }));
});
},
);
},
getBounces() {
this.$api.getSubscriberBounces(this.form.id).then((data) => {
this.bounces = data;
});
},
onSubmit() {
if (this.isEditing) {
this.updateSubscriber();
return;
}
this.createSubscriber();
},
createSubscriber() {
let attribs = {};
if (this.form.strAttribs) {
attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
}
const data = {
email: this.form.email,
name: this.form.name,
status: this.form.status,
attribs,
preconfirm_subscriptions: this.form.preconfirm,
// List IDs.
lists: this.form.lists.map((l) => l.id),
};
this.$api.createSubscriber(data).then((d) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: d.name }));
});
},
updateSubscriber() {
let attribs = {};
if (this.form.strAttribs) {
attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
}
const data = {
id: this.form.id,
email: this.form.email,
name: this.form.name,
status: this.form.status,
preconfirm_subscriptions: this.form.preconfirm,
attribs,
// List IDs.
lists: this.form.lists.map((l) => l.id),
};
this.$api.updateSubscriber(data).then((d) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: d.name }));
});
},
sendOptinConfirmation() {
this.$api.sendSubscriberOptin(this.form.id).then(() => {
this.$utils.toast(this.$t('subscribers.sentOptinConfirm'));
});
},
validateAttribs(str) {
// Parse and validate attributes JSON.
let attribs = {};
try {
attribs = JSON.parse(str);
} catch (e) {
this.$utils.toast(
`${this.$t('subscribers.invalidJSON')}: ${e.toString()}`,
'is-danger',
3000,
);
return null;
}
if (attribs instanceof Array) {
this.$utils.toast('Attributes should be a map {} and not an array []', 'is-danger', 3000);
return null;
}
return attribs;
},
},
computed: {
...mapState(['lists', 'loading']),
hasOptinList() {
return this.form.lists.some((l) => l.optin === 'double');
},
},
mounted() {
if (this.$props.isEditing) {
this.form = {
...this.$props.data,
// Deep-copy the lists array on to the form.
strAttribs: JSON.stringify(this.$props.data.attribs, null, 4),
};
}
if (this.form.id) {
this.getBounces();
}
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>
+550
View File
@@ -0,0 +1,550 @@
<template>
<section class="subscribers">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">
(<span data-cy="count">{{ subscribers.total }}</span>)
</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
<span v-if="queryParams.subStatus" class="has-text-grey has-text-weight-normal is-capitalized">({{
queryParams.subStatus }})</span>
</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('subscribers:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new" class="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<section class="subscribers-controls">
<div class="columns">
<div class="column is-8">
<form @submit.prevent="onSubmit">
<div>
<b-field addons>
<b-input @input="onSimpleQueryInput" v-model="queryInput" expanded
:placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced" data-cy="search" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" :disabled="isSearchAdvanced"
data-cy="btn-search" />
</p>
</b-field>
<div v-if="isSearchAdvanced">
<b-input v-model="queryParams.queryExp" @keydown.native.enter="onAdvancedQueryEnter" type="textarea"
ref="queryExp" placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
data-cy="query" />
<span class="is-size-6 has-text-grey">
{{ $t('subscribers.advancedQueryHelp') }}.
</span>
<div class="buttons">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query">
{{
$t('subscribers.query') }}
</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel" data-cy="btn-query-reset">
{{ $t('subscribers.reset') }}
</b-button>
</div>
</div><!-- advanced query -->
</div>
</form>
<div v-if="!isSearchAdvanced" class="toggle-advanced">
<a href="#" @click.prevent="toggleAdvancedSearch" data-cy="btn-advanced-search">
<b-icon icon="cog-outline" size="is-small" />
{{ $t('subscribers.advancedQuery') }}
</a>
</div>
</div><!-- search -->
</div>
</section><!-- control -->
<br />
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck"
@check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
@page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage"
:total="subscribers.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="actions">
<a class="a" href="#" @click.prevent="exportSubscribers" data-cy="btn-export-subscribers">
<b-icon icon="cloud-download-outline" size="is-small" />
{{ $t('subscribers.export') }}
</a>
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
</a>
<a class="a" href="#" @click.prevent="deleteSubscribers" data-cy="btn-delete-subscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<a class="a" href="#" @click.prevent="blocklistSubscribers" data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
</a>
<span class="a">
{{ $t('globals.messages.numSelected', { num: numSelectedSubscribers }) }}
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash;
<a href="#" @click.prevent="selectAllSubscribers">
{{ $t('globals.messages.selectAll', { num: subscribers.total }) }}
</a>
</span>
</span>
</template>
</div>
</template>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" header-class="cy-email" sortable
:td-attrs="$utils.tdID">
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
{{ props.row.email }}
<copy-text :text="`${props.row.email}`" hide-text />
</a>
<b-tag v-if="props.row.status !== 'enabled'" :class="props.row.status" data-cy="blocklisted">
{{ $t(`subscribers.status.${props.row.status}`) }}
</b-tag>
<b-taglist>
<template v-for="l in props.row.lists">
<router-link :to="`/subscribers/lists/${l.id}`" :key="l.id" style="padding-right:0.5em;">
<b-tag :class="l.subscriptionStatus" size="is-small" :key="l.id">
{{ l.name }}
<sup v-if="l.optin === 'double' || l.subscriptionStatus == 'unsubscribed'">
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
</sup>
</b-tag>
</router-link>
</template>
</b-taglist>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
{{ props.row.name }}
<copy-text :text="`${props.row.name}`" hide-text />
</a>
</b-table-column>
<b-table-column v-slot="props" field="lists" :label="$t('globals.terms.lists')" header-class="cy-lists" centered>
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download"
:aria-label="$t('subscribers.downloadData')">
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="$can('subscribers:manage')" :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)" data-cy="btn-edit" :aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="$can('subscribers:manage')" href="#" @click.prevent="deleteSubscriber(props.row)"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.subscribers">
<empty-placeholder />
</template>
</b-table>
<!-- Manage list modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="500" class="has-overflow">
<subscriber-bulk-list :num-subscribers="this.numSelectedSubscribers" @finished="bulkChangeLists" />
</b-modal>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="850" @close="onFormClose">
<subscriber-form :data="curItem" :is-editing="isEditing" @finished="querySubscribers" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import { uris } from '../constants';
import SubscriberBulkList from './SubscriberBulkList.vue';
import SubscriberForm from './SubscriberForm.vue';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
components: {
SubscriberForm,
SubscriberBulkList,
CopyText,
EmptyPlaceholder,
},
data() {
return {
// Current subscriber item being edited.
curItem: null,
isSearchAdvanced: false,
isEditing: false,
isFormVisible: false,
isBulkListFormVisible: false,
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
queryInput: '',
// Query params to filter the getSubscribers() API call.
queryParams: {
// Search query expression.
queryExp: '',
search: '',
// ID of the list the current subscriber view is filtered by.
listID: null,
page: 1,
orderBy: 'id',
order: 'desc',
subStatus: null,
},
};
},
methods: {
// Count the lists from which a subscriber has not unsubscribed.
listCount(lists) {
return lists.reduce((defVal, item) => (defVal + (item.subscriptionStatus !== 'unsubscribed' ? 1 : 0)), 0);
},
toggleAdvancedSearch() {
this.isSearchAdvanced = !this.isSearchAdvanced;
this.queryParams.search = '';
// Toggling to simple search.
if (!this.isSearchAdvanced) {
this.queryInput = '';
this.queryParams.queryExp = '';
this.queryParams.page = 1;
this.querySubscribers();
this.$refs.query.focus();
return;
}
// Toggling to advanced search.
const q = this.queryInput.replace(/'/, "''").trim();
if (q) {
if (this.$utils.validateEmail(q)) {
this.queryParams.queryExp = `email = '${q.toLowerCase()}'`;
} else {
this.queryParams.queryExp = `(name ~* '${q}' OR email ~* '${q.toLowerCase()}')`;
}
}
// Toggling to advanced search.
this.$nextTick(() => {
this.$refs.queryExp.focus();
});
},
// Mark all subscribers in the query as selected.
selectAllSubscribers() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.subscribers.total) {
this.bulk.all = false;
}
},
// Show the edit list form.
showEditForm(sub) {
this.curItem = sub;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new list form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
showBulkListForm() {
this.isBulkListFormVisible = true;
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'subscribers' });
}
},
onPageChange(p) {
this.querySubscribers({ page: p });
},
onSort(field, direction) {
this.querySubscribers({ orderBy: field, order: direction });
},
// Prepares an SQL expression for simple name search inputs and saves it
// in this.queryExp.
onSimpleQueryInput(v) {
const q = v.replace(/'/, "''").trim();
this.queryParams.queryExp = '';
this.queryParams.page = 1;
this.queryParams.search = q.toLowerCase();
},
// Ctrl + Enter on the advanced query searches.
onAdvancedQueryEnter(e) {
if (e.ctrlKey) {
this.onSubmit();
}
},
onSubmit() {
this.querySubscribers({ page: 1 });
},
// Search / query subscribers.
querySubscribers(params) {
this.queryParams = { ...this.queryParams, ...params };
const qp = {
list_id: this.queryParams.listID,
search: this.queryParams.search,
query: this.queryParams.queryExp,
page: this.queryParams.page,
subscription_status: this.queryParams.subStatus,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
};
if (this.queryParams.queryExp) {
delete qp.search;
} else {
delete qp.queryExp;
}
this.$nextTick(() => {
this.$api.getSubscribers(qp).then(() => {
this.bulk.checked = [];
});
});
},
deleteSubscriber(sub) {
this.$utils.confirm(
null,
() => {
this.$api.deleteSubscriber(sub.id).then(() => {
this.querySubscribers();
this.$utils.toast(this.$t('globals.messages.deleted', { name: sub.name }));
});
},
);
},
blocklistSubscribers() {
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, blocklist subscribers by IDs.
fn = () => {
const ids = this.bulk.checked.map((s) => s.id);
this.$api.blocklistSubscribers({ ids })
.then(() => this.querySubscribers());
};
} else {
// 'All' is selected, blocklist by query.
fn = () => {
this.$api.blocklistSubscribersByQuery({
search: this.queryParams.search,
query: this.queryParams.queryExp,
list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
subscription_status: this.queryParams.subStatus,
}).then(() => this.querySubscribers());
};
}
this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), fn);
},
exportSubscribers() {
const num = !this.bulk.all && this.bulk.checked.length > 0
? this.bulk.checked.length : this.subscribers.total;
this.$utils.confirm(this.$t('subscribers.confirmExport', { num }), () => {
const q = new URLSearchParams();
if (this.queryParams.search) {
q.append('search', this.queryParams.search);
} else if (this.queryParams.queryExp) {
q.append('query', this.queryParams.queryExp);
}
if (this.queryParams.listID) {
q.append('list_id', this.queryParams.listID);
}
if (this.queryParams.subStatus) {
q.append('subscription_status', this.queryParams.subStatus);
}
// Export selected subscribers.
if (!this.bulk.all && this.bulk.checked.length > 0) {
this.bulk.checked.map((s) => q.append('id', s.id));
}
document.location.href = `${uris.exportSubscribers}?${q.toString()}`;
});
},
deleteSubscribers() {
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete subscribers by IDs.
fn = () => {
const ids = this.bulk.checked.map((s) => s.id);
this.$api.deleteSubscribers({ id: ids })
.then(() => {
this.querySubscribers();
this.$utils.toast(this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }));
});
};
} else {
// 'All' is selected, delete by query.
fn = () => {
this.$api.deleteSubscribersByQuery({
// If the query expression is empty, explicitly pass `all=true`
// so that the backend deletes all records in the DB with an empty query string.
all: this.queryParams.queryExp.trim() === '' && this.queryParams.search.trim() === '',
search: this.queryParams.search,
query: this.queryParams.queryExp,
list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
subscription_status: this.queryParams.subStatus,
}).then(() => {
this.querySubscribers();
this.$utils.toast(this.$t(
'subscribers.subscribersDeleted',
{ num: this.numSelectedSubscribers },
));
});
};
}
this.$utils.confirm(this.$t('subscribers.confirmDelete', { num: this.numSelectedSubscribers }), fn);
},
bulkChangeLists(action, preconfirm, lists) {
const data = {
action,
query: this.fullQueryExp,
search: this.queryParams.search,
list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
target_list_ids: lists.map((l) => l.id),
};
if (preconfirm) {
data.status = 'confirmed';
}
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, perform by IDs.
fn = this.$api.addSubscribersToLists;
data.ids = this.bulk.checked.map((s) => s.id);
} else {
// 'All' is selected, perform by query.
data.query = this.queryParams.queryExp;
data.subscription_status = this.queryParams.subStatus;
fn = this.$api.addSubscribersToListsByQuery;
}
fn(data).then(() => {
this.querySubscribers();
this.$utils.toast(this.$t('subscribers.listChangeApplied'));
});
},
},
computed: {
...mapState(['subscribers', 'lists', 'loading']),
numSelectedSubscribers() {
if (this.bulk.all) {
return this.subscribers.total;
}
return this.bulk.checked.length;
},
// Returns the list that the subscribers are being filtered by in.
currentList() {
if (!this.queryParams.listID || !this.lists.results) {
return null;
}
return this.lists.results.find((l) => l.id === this.queryParams.listID);
},
},
created() {
this.$root.$on('page.refresh', this.querySubscribers);
},
destroyed() {
this.$root.$off('page.refresh', this.querySubscribers);
},
mounted() {
if (this.$route.params.listID) {
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
}
if (this.$route.query.subscription_status) {
this.queryParams.subStatus = this.$route.query.subscription_status;
}
if (this.$route.params.id) {
this.$api.getSubscriber(parseInt(this.$route.params.id, 10)).then((data) => {
this.showEditForm(data);
});
} else {
// Get subscribers on load.
this.querySubscribers();
}
},
});
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<section>
<form @submit.prevent="onSubmit">
<div class="modal-card content template-modal-content" style="width: auto">
<header class="modal-card-head">
<b-button @click="onTogglePreview" class="is-pulled-right" type="is-primary" icon-left="file-find-outline">
{{ $t('templates.preview') }} (F9)
</b-button>
<template v-if="isEditing">
<h4>{{ data.name }}</h4>
<p class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: <span data-cy="id"><copy-text :text="`${data.id}`" /></span>
</p>
</template>
<h4 v-else>
{{ $t('templates.newTemplate') }}
</h4>
</header>
<section expanded class="modal-card-body mb-0 pb-0">
<div class="columns">
<div class="column is-9">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('globals.fields.type')" label-position="on-border">
<b-select v-model="form.type" :disabled="isEditing" expanded>
<option value="campaign">
{{ $tc('templates.typeCampaignHTML') }}
</option>
<option value="campaign_visual">
{{ $tc('templates.typeCampaignVisual') }}
</option>
<option value="tx">
{{ $tc('templates.typeTransactional') }}
</option>
</b-select>
</b-field>
</div>
</div>
<div class="columns" v-if="form.type === 'tx'">
<div class="column is-12">
<b-field :label="$t('templates.subject')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.subject" name="name"
:placeholder="$t('templates.subject')" required />
</b-field>
</div>
</div>
<template v-if="form.body !== null">
<b-field v-if="form.type === 'campaign_visual'" label-position="on-border" class="mb-1">
<visual-editor v-if="form.type === 'campaign_visual'" name="body" :source="form.bodySource"
@change="onChangeVisualEditor" height="70vh" />
</b-field>
<b-field v-else :label="$t('templates.rawHTML')" label-position="on-border">
<code-editor lang="html" v-model="form.body" name="body" />
</b-field>
</template>
<p class="is-size-7">
<template v-if="form.type === 'campaign'">
{{ $t('templates.placeholderHelp', { placeholder: egPlaceholder }) }}
</template>
</p>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('templates:manage')" native-type="submit" type="is-primary" :loading="loading.templates">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
<campaign-preview v-if="previewItem" is-post type="template" :title="previewItem.name"
:template-type="previewItem.type" :body="form.body" @close="onTogglePreview" />
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
import CodeEditor from '../components/CodeEditor.vue';
import VisualEditor from '../components/VisualEditor.vue';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
components: {
CampaignPreview,
CopyText,
'code-editor': CodeEditor,
'visual-editor': VisualEditor,
},
props: {
data: { type: Object, default: () => { } },
isEditing: { type: Boolean, default: false },
},
data() {
return {
// Binds form input values.
form: {
name: '',
subject: '',
type: 'campaign',
optin: '',
body: null,
bodySource: null,
},
previewItem: null,
egPlaceholder: '{{ template "content" . }}',
};
},
methods: {
onTogglePreview() {
this.previewItem = !this.previewItem ? this.form : null;
},
onPreviewShortcut(e) {
if (e.key === 'F9') {
this.onTogglePreview();
e.preventDefault();
}
},
onSubmit() {
if (this.isEditing) {
this.updateTemplate();
return;
}
this.createTemplate();
},
createTemplate() {
const data = {
id: this.data.id,
name: this.form.name,
type: this.form.type,
subject: this.form.subject,
body: this.form.body,
body_source: this.form.bodySource,
};
this.$api.createTemplate(data).then((d) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: d.name }));
});
},
updateTemplate() {
const data = {
id: this.data.id,
name: this.form.name,
type: this.form.type,
subject: this.form.subject,
body: this.form.body,
body_source: this.form.bodySource,
};
this.$api.updateTemplate(data).then((d) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(`'${d.name}' updated`);
});
},
onChangeVisualEditor({ source, body }) {
this.form.body = body;
this.form.bodySource = source;
},
},
computed: {
...mapState(['loading']),
},
mounted() {
this.form = { ...this.$props.data };
this.$nextTick(() => {
this.$refs.focus.focus();
});
window.addEventListener('keydown', this.onPreviewShortcut);
},
beforeDestroy() {
window.removeEventListener('keydown', this.onPreviewShortcut);
},
});
</script>
+219
View File
@@ -0,0 +1,219 @@
<template>
<section class="templates">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.templates') }}
<span v-if="templates.length > 0">({{ templates.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('templates:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="templates" :hoverable="true" :loading="loading.templates" default-sort="createdAt">
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" :td-attrs="$utils.tdID" sortable>
<a href="#" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-tag v-if="props.row.isDefault">
{{ $t('templates.default') }}
</b-tag>
<p class="is-size-7 has-text-grey" v-if="props.row.type === 'tx'">
{{ props.row.subject }}
</p>
</b-table-column>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
<b-tag v-if="props.row.type === 'campaign'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $tc('templates.typeCampaignHTML') }}
</b-tag>
<b-tag v-else-if="props.row.type === 'campaign_visual'" :class="props.row.type"
:data-cy="`type-${props.row.type}`">
{{ $tc('templates.typeCampaignVisual') }}
</b-tag>
<b-tag v-else :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $tc('templates.typeTransactional') }}
</b-tag>
</b-table-column>
<b-table-column v-slot="props" field="id" :label="$t('globals.fields.id')" sortable>
{{ props.row.id }}
</b-table-column>
<b-table-column v-slot="props" field="createdAt" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updatedAt" :label="$t('globals.fields.updatedAt')" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a href="#" @click.prevent="previewTemplate(props.row)" data-cy="btn-preview"
:aria-label="$t('templates.preview')">
<b-tooltip :label="$t('templates.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}` },
(name) => cloneTemplate(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault && props.row.type === 'campaign'" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))" data-cy="btn-set-default"
:aria-label="$t('templates.makeDefault')">
<b-tooltip :label="$t('templates.makeDefault')" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="check-circle-outline" size="is-small" />
</span>
<a v-if="!props.row.isDefault" href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="trash-can-outline" size="is-small" />
</span>
</div>
</b-table-column>
<template #empty v-if="!loading.templates">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="1200" :can-cancel="false"
class="template-modal">
<template-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
<campaign-preview v-if="previewItem" type="template" :id="previewItem.id" :template-type="previewItem.type"
:title="previewItem.name" @close="closePreview" />
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import TemplateForm from './TemplateForm.vue';
export default Vue.extend({
components: {
CampaignPreview,
TemplateForm,
EmptyPlaceholder,
},
data() {
return {
curItem: null,
isEditing: false,
isFormVisible: false,
previewItem: null,
};
},
methods: {
fetchTemplates() {
this.$api.getTemplates();
},
// Show the edit form.
showEditForm(data) {
this.curItem = data;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = { type: 'campaign' };
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.$api.getTemplates();
},
previewTemplate(c) {
this.previewItem = c;
},
closePreview() {
this.previewItem = null;
},
cloneTemplate(name, t) {
const data = {
name,
type: t.type,
subject: t.subject,
body: t.body,
body_source: t.bodySource,
};
this.$api.createTemplate(data).then((d) => {
this.$api.getTemplates();
this.$emit('finished');
this.$utils.toast(`'${d.name}' created`);
});
},
makeTemplateDefault(tpl) {
this.$api.makeTemplateDefault(tpl.id).then(() => {
this.$api.getTemplates();
this.$utils.toast(this.$t('globals.messages.created', { name: tpl.name }));
});
},
deleteTemplate(tpl) {
this.$api.deleteTemplate(tpl.id).then(() => {
this.$api.getTemplates();
this.$utils.toast(this.$t('globals.messages.deleted', { name: tpl.name }));
});
},
},
computed: {
...mapState(['templates', 'loading']),
},
created() {
this.$root.$on('page.refresh', this.fetchTemplates);
},
destroyed() {
this.$root.$off('page.refresh', this.fetchTemplates);
},
mounted() {
this.$api.getTemplates();
},
});
</script>
+244
View File
@@ -0,0 +1,244 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
</p>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('users.newUser') }}
</h4>
</header>
<section expanded class="modal-card-body">
<div class="columns">
<div class="column is-6">
<b-field label-position="on-border" class="mb-6">
<b-radio-button v-model="form.type" name="type" native-value="user" :disabled="isEditing" expanded
type="is-light is-outlined">
<b-icon icon="account-outline" />
{{ $t('users.type.user') }}
</b-radio-button>
<b-radio-button v-model="form.type" name="type" native-value="api" :disabled="isEditing" expanded
type="is-light is-outlined">
<b-icon icon="code" />
{{ $t('users.type.api') }}
</b-radio-button>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('globals.fields.status')" label-position="on-border">
<b-select v-model="form.status" name="status" required expanded>
<option value="enabled">
{{ $t('users.status.enabled') }}
</option>
<option value="disabled">
{{ $t('users.status.disabled') }}
</option>
</b-select>
</b-field>
</div>
</div>
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" ref="focus" autofocus
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
pattern="[a-zA-Z0-9_\-\.@]+$" />
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required />
</b-field>
<template v-if="form.type !== 'api'">
<div class="box">
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true" name="password_login">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
</div>
</div>
</div>
</template>
<h5>{{ $tc('users.roles') }}</h5>
<div class="box">
<div class="columns">
<div class="column is-6">
<b-field :label="$tc('users.userRole')" label-position="on-border">
<b-select v-model="form.userRoleId" name="user_role" required expanded>
<option v-for="r in userRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$tc('users.listRole', 0)" label-position="on-border">
<b-select v-model="form.listRoleId" name="list_role" expanded>
<option value="">&mdash; {{ $t("globals.terms.none") }} &mdash;</option>
<option v-for="r in listRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
</div>
</div>
<div v-if="apiToken" class="user-api-token">
<p>{{ $t('users.apiOneTimeToken') }}</p>
<copy-text :text="apiToken" />
</div>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('users:manage') && !apiToken" native-type="submit" type="is-primary"
:loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'UserForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
},
data() {
return {
// Binds form input values.
form: {
username: '',
email: '',
name: '',
password: '',
passwordLogin: false,
type: 'user',
status: 'enabled',
},
apiToken: null,
};
},
methods: {
onSubmit() {
if (!this.form.passwordLogin) {
this.form.password = null;
this.form.password2 = null;
}
if (this.isEditing) {
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
this.updateUser();
return;
}
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
this.createUser();
},
createUser() {
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.createUser(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
// If the user is an API user, show the one-time token.
if (form.type === 'api') {
this.apiToken = data.password;
return;
}
this.$emit('finished');
this.$parent.close();
});
},
updateUser() {
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.updateUser({ id: this.data.id, ...form }).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
});
},
hasType(t) {
// If the user being edited is API, then the only valid field is API.
// Otherwise, all fields are valid except API.
return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api');
},
},
computed: {
...mapState(['loading', 'userRoles', 'listRoles']),
},
mounted() {
this.form = { ...this.form, ...this.$props.data };
if (this.$props.data.userRole) {
this.form.userRoleId = this.$props.data.userRole.id;
}
this.form.listRoleId = this.$props.data.listRole ? this.$props.data.listRole.id : '';
this.$api.getUserRoles();
this.$api.getListRoles();
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>
+278
View File
@@ -0,0 +1,278 @@
<template>
<section class="user-profile section-mini">
<b-loading v-if="loading.users" :active="loading.users" :is-full-page="false" />
<h1 class="title">
@{{ data.username }}
</h1>
<b-tag v-if="data.userRole">{{ data.userRole.name }}</b-tag>
<br /><br /><br />
<form @submit.prevent="onSubmit">
<b-field v-if="data.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')"
:disabled="!data.passwordLogin" required autofocus />
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<div v-if="data.passwordLogin" class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password" type="password" name="password"
:placeholder="$t('users.password')" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password2" type="password" name="password2" />
</b-field>
</div>
</div>
<b-field expanded>
<b-button type="is-primary" icon-left="content-save-outline" native-type="submit" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</b-field>
</form>
<br /><br />
<!-- 2FA -->
<section v-if="this.data.passwordLogin" class="twofa-section">
<!-- TOTP disabled -->
<div v-if="data.twofaType === 'none'" class="box">
<div class="columns is-vcentered mb-4">
<div class="column">
<h3 class="title is-size-5 mb-0">{{ $t('users.twoFA') }}</h3>
</div>
<div class="column is-narrow">
<b-switch v-if="!isTotpVisible" v-model="twofaEnabled" @input="onToggleEnableTotp" />
</div>
</div>
<p>{{ $t('users.twoFANotEnabled') }}</p>
<br />
<!-- TOTP setup -->
<div v-if="isTotpVisible" class="totp-setup">
<div v-if="totpQR" class="qr-section">
<p class="has-text-grey">{{ $t('users.totpScanQR') }}</p><br />
<img :src="'data:image/png;base64,' + totpQR" alt="QR Code" />
<br /><br />
<p>
<strong>{{ $t('users.totpSecret') }}</strong><br />
<code><copy-text :text="`${totpSecret}`" /></code>
</p>
<br /><br />
<form @submit.prevent="confirmTOTP">
<b-field :label="$t('users.totpCode')" label-position="on-border">
<b-input ref="totpCodeInput" v-model="totpCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
required />
</b-field>
<div class="buttons">
<b-button type="is-primary" native-type="submit">
{{ $t('globals.buttons.enable') }}
</b-button>
<b-button type="button" @click="onCancelTOTPSetup">
{{ $t('globals.buttons.cancel') }}
</b-button>
</div>
</form>
</div>
</div>
</div>
<!-- TOTP Enabled -->
<div v-if="data.twofaType === 'totp'" class="box">
<div class="columns is-vcentered">
<div class="column">
<h3 class="title is-size-5">
<b-icon icon="check-circle-outline" type="is-success" /> {{ $t('users.twoFAEnabled') }}
</h3>
</div>
<div class="column is-narrow">
<b-switch v-if="!showDisableTOTP" v-model="twofaEnabled" @input="toggleDisableTOTP" />
</div>
</div>
<p>{{ $t('users.twoFAEnabledDesc', { type: data.twofaType.toUpperCase() }) }}</p>
<!-- Disable TOTP Flow -->
<form v-if="showDisableTOTP" class="disable-totp mt-5" @submit.prevent="confirmDisableTOTP">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input ref="disablePasswordInput" v-model="disableTOTPPassword" type="password" minlength="8" required />
</b-field>
<div class="buttons">
<b-button type="is-danger" native-type="submit">
{{ $t('globals.buttons.disable') }}
</b-button>
<b-button type="button" @click="onCancelTOTPSetup">
{{ $t('globals.buttons.cancel') }}
</b-button>
</div>
</form>
</div>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'UserProfile',
components: {
CopyText,
},
data() {
return {
form: {},
data: {},
isTotpVisible: false,
totpQR: null,
totpSecret: null,
totpCode: '',
showDisableTOTP: false,
disableTOTPPassword: '',
twofaEnabled: false,
};
},
methods: {
onSubmit() {
const params = {
name: this.form.name,
email: this.form.email,
};
if (this.data.passwordLogin && this.form.password) {
if (this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
params.password = this.form.password;
params.password2 = this.form.password2;
}
this.$api.updateUserProfile(params).then(() => {
this.form.password = '';
this.form.password2 = '';
this.$utils.toast(this.$t('globals.messages.updated', { name: this.data.username }));
});
},
onToggleEnableTotp() {
this.$api.getTOTPQR(this.data.id).then((data) => {
this.totpQR = data.qr;
this.totpSecret = data.secret;
this.isTotpVisible = true;
this.$nextTick(() => {
if (this.$refs.totpCodeInput) {
this.$refs.totpCodeInput.focus();
}
});
}).catch(() => {
this.$utils.toast(this.$t('globals.messages.errorFetching'), 'is-danger');
});
},
onCancelTOTPSetup() {
this.isTotpVisible = false;
this.totpQR = null;
this.totpSecret = null;
this.totpCode = '';
this.twofaEnabled = this.data.twofaType === 'totp';
this.showDisableTOTP = false;
this.disableTOTPPassword = '';
},
confirmTOTP() {
if (!this.totpCode || this.totpCode.length !== 6) {
this.$utils.toast(this.$t('globals.messages.invalidValue'), 'is-danger');
return;
}
const d = new FormData();
d.append('secret', this.totpSecret);
d.append('code', this.totpCode);
this.$api.enableTOTP(this.data.id, d).then(() => {
this.$utils.toast(this.$t('users.twoFAEnabled'));
this.onCancelTOTPSetup();
// Reload user profile
this.$api.getUserProfile().then((data) => {
this.data = { ...data };
this.twofaEnabled = data.twofaType === 'totp';
});
}).catch(() => {
this.$utils.toast(this.$t('globals.messages.invalidValue'), 'is-danger');
});
},
toggleDisableTOTP() {
this.showDisableTOTP = true;
this.$nextTick(() => {
if (this.$refs.disablePasswordInput) {
this.$refs.disablePasswordInput.focus();
}
});
},
cancelDisableTOTP() {
this.showDisableTOTP = false;
this.disableTOTPPassword = '';
},
confirmDisableTOTP() {
if (!this.disableTOTPPassword) {
this.$utils.toast(this.$t('globals.messages.invalidFields'), 'is-danger');
return;
}
const formData = new FormData();
formData.append('password', this.disableTOTPPassword);
this.$api.disableTOTP(this.data.id, formData).then(() => {
this.$utils.toast(this.$t('globals.messages.done'));
this.showDisableTOTP = false;
this.disableTOTPPassword = '';
// Reload user profile
this.$api.getUserProfile().then((data) => {
this.data = { ...data };
this.twofaEnabled = data.twofaType === 'totp';
});
}).catch(() => {
this.$utils.toast(this.$t('users.invalidPassword'), 'is-danger');
});
},
},
mounted() {
this.$api.getUserProfile().then((data) => {
this.data = { ...data };
this.form = { name: data.name, email: data.email };
this.twofaEnabled = data.twofaType === 'totp';
});
},
computed: {
...mapState(['loading']),
},
});
</script>
+244
View File
@@ -0,0 +1,244 @@
<template>
<section class="users">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.users') }}
<span v-if="!isNaN(users.length)">({{ users.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('users:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="users" :loading="loading.users" hoverable checkable :checked-rows.sync="checked"
default-sort="createdAt" backend-sorting @sort="onSort" @check-all="onTableCheck" @check="onTableCheck">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getUsers">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</div>
</form>
</div>
</div>
</template>
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
:td-attrs="$utils.tdID">
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.username }}
</a>
<b-tag v-if="props.row.status === 'disabled'">
{{ $t(`users.status.${props.row.status}`) }}
</b-tag>
<b-tag v-if="props.row.type === 'api'" class="api">
<b-icon icon="code" />
{{ $t(`users.type.${props.row.type}`) }}
</b-tag>
<div class="has-text-grey is-size-7 mt-2">
{{ props.row.name }}
</div>
</b-table-column>
<b-table-column v-slot="props" field="status" :label="$tc('users.role')" header-class="cy-status" sortable
:td-attrs="$utils.tdID">
<router-link :to="{ name: 'userRoles' }">
<b-tag :class="props.row.userRole.id === 1 ? 'enabled' : 'primary'">
<b-icon icon="account-outline" />
{{ props.row.userRole.name }}
</b-tag>
</router-link>
<router-link :to="{ name: 'listRoles' }">
<b-tag v-if="props.row.listRole">
<b-icon icon="newspaper-variant-outline" />
{{ props.row.listRole.name }}
</b-tag>
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable
:td-attrs="$utils.tdID">
<div>
<a v-if="props.row.email" :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.email }}
</a>
<template v-else>
</template>
</div>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" field="last_login" :label="$t('users.lastLogin')" header-class="cy-updated_at"
sortable>
{{ props.row.loggedinAt ? $utils.niceDate(props.row.loggedinAt, true) : '—' }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a v-if="$can('users:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="$can('users:manage')" href="#" @click.prevent="deleteUser(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.users">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600" @close="onFormClose">
<user-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import UserForm from './UserForm.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
UserForm,
},
data() {
return {
curItem: null,
isEditing: false,
isFormVisible: false,
users: [],
checked: [],
queryParams: {
page: 1,
query: '',
orderBy: 'id',
order: 'asc',
},
};
},
methods: {
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getUsers();
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.subscribers.total) {
this.bulk.all = false;
}
},
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.getUsers();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'users' });
}
},
getUsers() {
this.$api.queryUsers({
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
}).then((resp) => {
this.users = resp;
});
},
deleteUser(item) {
this.$utils.confirm(
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteUser(item.id).then(() => {
this.getUsers();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
},
);
},
},
computed: {
...mapState(['loading', 'settings']),
},
created() {
this.$root.$on('page.refresh', this.getUsers);
},
destroyed() {
this.$root.$off('page.refresh', this.getUsers);
},
mounted() {
if (this.$route.params.id) {
this.$api.getUser(parseInt(this.$route.params.id, 10)).then((data) => {
this.showEditForm(data);
});
} else {
this.getUsers();
}
},
});
</script>
@@ -0,0 +1,75 @@
<template>
<div class="items">
<b-tabs :animated="false" v-model="tab">
<b-tab-item :label="$t('settings.appearance.adminName')" label-position="on-border">
<div class="block">
{{ $t('settings.appearance.adminHelp') }}
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<code-editor lang="css" v-model="data['appearance.admin.custom_css']" name="body" key="editor-admin-css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<code-editor lang="javascript" v-model="data['appearance.admin.custom_js']" name="body"
key="editor-admin-js" />
</b-field>
</b-tab-item><!-- admin -->
<b-tab-item :label="$t('settings.appearance.publicName')" label-position="on-border">
<div class="block">
{{ $t('settings.appearance.publicHelp') }}
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<code-editor lang="css" v-model="data['appearance.public.custom_css']" name="body" key="editor-public-css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<code-editor lang="javascript" v-model="data['appearance.public.custom_js']" name="body"
key="editor-public-js" />
</b-field>
</b-tab-item><!-- public -->
</b-tabs>
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CodeEditor from '../../components/CodeEditor.vue';
export default Vue.extend({
components: {
'code-editor': CodeEditor,
},
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
tab: 0,
};
},
mounted() {
this.tab = this.$utils.getPref('settings.apperanceTab') || 0;
},
watch: {
tab(t) {
this.$utils.setPref('settings.apperanceTab', t);
},
},
computed: {
...mapState(['settings']),
},
});
</script>
+298
View File
@@ -0,0 +1,298 @@
<template>
<div>
<div class="columns mb-6">
<div class="column is-4">
<b-field data-cy="btn-enable-bounce">
<b-switch v-model="data['bounce.enabled']" name="bounce.enabled">
{{ $t('settings.bounces.enable') }}
</b-switch>
</b-field>
</div>
<div class="column">
<div v-for="typ in bounceTypes" :key="typ" class="columns">
<div class="column is-2" :class="{ disabled: !data['bounce.enabled'] }" :label="$t('settings.bounces.count')"
label-position="on-border">
{{ $t(`bounces.${typ}`) }}
</div>
<div class="column is-4" :class="{ disabled: !data['bounce.enabled'] }">
<b-field :label="$t('settings.bounces.count')" label-position="on-border"
:message="$t('settings.bounces.countHelp')" data-cy="btn-bounce-count">
<b-numberinput v-model="data['bounce.actions'][typ]['count']" name="bounce.count" type="is-light"
controls-position="compact" placeholder="3" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4" :class="{ disabled: !data['bounce.enabled'] }">
<b-field :label="$t('settings.bounces.action')" label-position="on-border">
<b-select name="bounce.action" v-model="data['bounce.actions'][typ]['action']" expanded>
<option value="none">
{{ $t('globals.terms.none') }}
</option>
<option value="unsubscribe">
{{ $t('email.unsub') }}
</option>
<option value="blocklist">
{{ $t('settings.bounces.blocklist') }}
</option>
<option value="delete">
{{ $t('globals.buttons.delete') }}
</option>
</b-select>
</b-field>
</div>
</div>
</div>
</div><!-- columns -->
<div class="mb-6">
<b-field data-cy="btn-enable-bounce-webhook">
<b-switch v-model="data['bounce.webhooks_enabled']" :disabled="!data['bounce.enabled']" name="webhooks_enabled"
:native-value="true" data-cy="btn-enable-bounce-webhook">
{{ $t('settings.bounces.enableWebhooks') }}
</b-switch>
</b-field>
<div class="box" v-if="data['bounce.webhooks_enabled']">
<div class="columns">
<div class="column">
<b-field>
<b-switch v-model="data['bounce.ses_enabled']" name="ses_enabled" :native-value="true"
data-cy="btn-enable-bounce-ses">
{{ $t('settings.bounces.enableSES') }}
</b-switch>
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field>
<b-switch v-model="data['bounce.azure'].enabled" name="azure_enabled" :native-value="true"
data-cy="btn-enable-bounce-azure">
{{ $t('settings.bounces.enableAzure') }}
</b-switch>
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.azureSharedSecret')" :message="$t('settings.bounces.azureSharedSecretHelp')">
<b-input v-model="data['bounce.azure'].shared_secret" type="password"
:disabled="!data['bounce.azure'].enabled" name="azure_shared_secret"
data-cy="bounce-azure-shared-secret" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.azureSharedSecretHeader')" :message="$t('settings.bounces.azureSharedSecretHeaderHelp')">
<b-input v-model="data['bounce.azure'].shared_secret_header" type="text"
:disabled="!data['bounce.azure'].enabled" name="azure_shared_secret_header"
data-cy="bounce-azure-shared-secret-header" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field>
<b-switch v-model="data['bounce.sendgrid_enabled']" name="sendgrid_enabled" :native-value="true"
data-cy="btn-enable-bounce-sendgrid">
{{ $t('settings.bounces.enableSendgrid') }}
</b-switch>
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.sendgridKey')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.sendgrid_key']" type="password"
:disabled="!data['bounce.sendgrid_enabled']" name="sendgrid_enabled" :native-value="true"
data-cy="btn-enable-bounce-sendgrid" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field>
<b-switch v-model="data['bounce.postmark'].enabled" name="postmark_enabled" :native-value="true"
data-cy="btn-enable-bounce-postmark">
{{ $t('settings.bounces.enablePostmark') }}
</b-switch>
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkUsername')"
:message="$t('settings.bounces.postmarkUsernameHelp')">
<b-input v-model="data['bounce.postmark'].username" type="text"
:disabled="!data['bounce.postmark'].enabled" name="postmark_username"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkPassword')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.postmark'].password" type="password"
:disabled="!data['bounce.postmark'].enabled" name="postmark_password"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field>
<b-switch v-model="data['bounce.forwardemail'].enabled" name="forwardemail_enabled" :native-value="true"
data-cy="btn-enable-bounce-forwardemail">
{{ $t('settings.bounces.enableForwardemail') }}
</b-switch>
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.forwardemailKey')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.forwardemail'].key" type="password"
:disabled="!data['bounce.forwardemail'].enabled" name="forwardemail_enabled" :native-value="true"
data-cy="btn-enable-bounce-forwardemail" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field>
<b-switch v-model="data['bounce.lettermint'].enabled" name="lettermint_enabled" :native-value="true"
data-cy="btn-enable-bounce-lettermint">
{{ $t('settings.bounces.enableLettermint') }}
</b-switch>
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.lettermintKey')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.lettermint'].key" type="password"
:disabled="!data['bounce.lettermint'].enabled" name="lettermint_key" data-cy="bounce-lettermint-key" />
</b-field>
</div>
</div>
</div>
</div>
<!-- bounce mailbox -->
<b-field>
<b-switch v-if="data['bounce.mailboxes']" v-model="data['bounce.mailboxes'][0].enabled"
:disabled="!data['bounce.enabled']" name="enabled" :native-value="true" data-cy="btn-enable-bounce-mailbox">
{{ $t('settings.bounces.enableMailbox') }}
</b-switch>
</b-field>
<template v-if="data['bounce.enabled'] && data['bounce.mailboxes'][0].enabled">
<div class="block box" v-for="(item, n) in data['bounce.mailboxes']" :key="n">
<div class="columns">
<div class="column" :class="{ disabled: !item.enabled }">
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.type')" label-position="on-border">
<b-select v-model="item.type" name="type" expanded>
<option value="pop">
POP
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
:message="$t('settings.mailserver.hostHelp')">
<b-input v-model="item.host" name="host" placeholder="bounce.yourmailserver.net" :maxlength="200" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
:message="$t('settings.mailserver.portHelp')">
<b-numberinput v-model="item.port" name="port" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
</div><!-- host -->
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.mailserver.authProtocol')" label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol" expanded>
<option value="none">
none
</option>
<option v-if="item.type === 'pop'" value="userpass">
userpass
</option>
<template v-else>
<option value="cram">
cram
</option>
<option value="plain">
plain
</option>
<option value="login">
login
</option>
</template>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.username')" label-position="on-border" expanded>
<b-input v-model="item.username" :disabled="item.auth_protocol === 'none'" name="username"
placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.mailserver.password')" label-position="on-border" expanded
:message="$t('settings.mailserver.passwordHelp')">
<b-input v-model="item.password" :disabled="item.auth_protocol === 'none'" name="password"
type="password" :placeholder="$t('settings.mailserver.passwordHelp')" :maxlength="200" />
</b-field>
</b-field>
</div>
</div><!-- auth -->
<div class="columns">
<div class="column is-6">
<b-field grouped>
<b-field expanded :message="$t('settings.mailserver.tlsHelp')">
<b-switch v-model="item.tls_enabled" name="item.tls_enabled">
{{ $t('settings.mailserver.tls') }}
</b-switch>
</b-field>
<b-field expanded :message="$t('settings.mailserver.skipTLSHelp')">
<b-switch v-model="item.tls_skip_verify" :disabled="!item.tls_enabled" name="item.tls_skip_verify">
{{ $t('settings.mailserver.skipTLS') }}
</b-switch>
</b-field>
</b-field>
</div>
<div class="column" />
<div class="column is-4">
<b-field :label="$t('settings.bounces.scanInterval')" expanded label-position="on-border"
:message="$t('settings.bounces.scanIntervalHelp')">
<b-input v-model="item.scan_interval" name="scan_interval" placeholder="15m" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div><!-- TLS -->
</div>
</div><!-- second container column -->
</div><!-- block -->
</template>
</div>
</template>
<script>
import Vue from 'vue';
import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
bounceTypes: ['soft', 'hard', 'complaint'],
data: this.form,
regDuration,
};
},
methods: {
removeBounceBox(i) {
this.data['bounce.mailboxes'].splice(i, 1);
},
},
});
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="items">
<b-field :label="$t('settings.general.siteName')" label-position="on-border">
<b-input v-model="data['app.site_name']" name="app.site_name" :label="$t('settings.general.siteName')"
:maxlength="300" required />
</b-field>
<b-field :label="$t('settings.general.rootURL')" label-position="on-border"
:message="$t('settings.general.rootURLHelp')">
<b-input v-model="data['app.root_url']" name="app.root_url" placeholder="https://eaglecast.yoursite.com"
:maxlength="300" required type="url" pattern="https?://.*" />
</b-field>
<b-field :label="$t('settings.general.logoURL')" label-position="on-border"
:message="$t('settings.general.logoURLHelp')">
<b-input v-model="data['app.logo_url']" name="app.logo_url" placeholder="https://eaglecast.yoursite.com/logo.png"
:maxlength="300" type="url" pattern="https?://.*" />
</b-field>
<b-field :label="$t('settings.general.faviconURL')" label-position="on-border"
:message="$t('settings.general.faviconURLHelp')">
<b-input v-model="data['app.favicon_url']" name="app.favicon_url"
placeholder="https://eaglecast.yoursite.com/favicon.png" :maxlength="300" type="url" pattern="https?://.*" />
</b-field>
<hr />
<b-field :label="$t('settings.general.fromEmail')" label-position="on-border"
:message="$t('settings.general.fromEmailHelp')">
<b-input v-model="data['app.from_email']" name="app.from_email"
placeholder="EagleCast <noreply@eaglecast.yoursite.com>" pattern="((.+?)\s)?<(.+?)@(.+?)>" :maxlength="300" />
</b-field>
<b-field :label="$t('settings.general.adminNotifEmails')" label-position="on-border"
:message="$t('settings.general.adminNotifEmailsHelp')">
<b-taginput v-model="data['app.notify_emails']" name="app.notify_emails"
:before-adding="(v) => v.match(/(.+?)@(.+?)/)" placeholder="you@yoursite.com" />
</b-field>
<hr />
<div>
<h2 class="is-size-4 mb-5">
{{ $tc('globals.terms.subscriptions', 2) }}
</h2>
<b-field :message="$t('settings.general.enablePublicSubPageHelp')">
<b-switch v-model="data['app.enable_public_subscription_page']" name="app.enable_public_subscription_page">
{{ $t('settings.general.enablePublicSubPage') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.general.sendOptinConfirmHelp')">
<b-switch v-model="data['app.send_optin_confirmation']" name="app.send_optin_confirmation">
{{ $t('settings.general.sendOptinConfirm') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.general.showOptinPageHelp')">
<b-switch v-model="data['app.show_optin_page']" name="app.show_optin_page">
{{ $t('settings.general.showOptinPage') }}
</b-switch>
</b-field>
</div>
<hr />
<div>
<h2 class="is-size-4 mb-5">
{{ $t('campaigns.archive') }}
</h2>
<b-field :message="$t('settings.general.enablePublicArchiveHelp')">
<b-switch v-model="data['app.enable_public_archive']" name="app.enable_public_archive">
{{ $t('settings.general.enablePublicArchive') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.general.enablePublicArchiveRSSContentHelp')">
<b-switch v-model="data['app.enable_public_archive_rss_content']" name="app.enable_public_archive_rss_content">
{{ $t('settings.general.enablePublicArchiveRSSContent') }}
</b-switch>
</b-field>
</div>
<hr />
<b-field :label="$t('settings.general.language')" label-position="on-border" :addons="false">
<b-select v-model="data['app.lang']" name="app.lang">
<option v-for="l in serverConfig.langs" :key="l.code" :value="l.code">
{{ l.name }}
</option>
</b-select>
</b-field>
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
};
},
computed: {
...mapState(['serverConfig', 'loading']),
},
});
</script>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div class="items">
<div class="columns">
<div class="column">
<b-field :label="$t('settings.media.provider')" label-position="on-border">
<b-select v-model="data['upload.provider']" name="upload.provider">
<option value="filesystem">
filesystem
</option>
<option value="s3">
s3
</option>
</b-select>
</b-field>
</div>
<div class="column is-10">
<b-field :label="$t('settings.media.upload.extensions')" label-position="on-border" expanded>
<b-taginput v-model="data['upload.extensions']" name="tags" ellipsis icon="tag-outline"
placeholder="jpg, png, gif .." />
</b-field>
</div>
</div>
<hr />
<div class="block" v-if="data['upload.provider'] === 'filesystem'">
<b-field :label="$t('settings.media.upload.path')" label-position="on-border"
:message="$t('settings.media.upload.pathHelp')">
<b-input v-model="data['upload.filesystem.upload_path']" name="app.upload_path"
placeholder="/home/eaglecast/uploads" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.media.upload.uri')" label-position="on-border"
:message="$t('settings.media.upload.uriHelp')">
<b-input v-model="data['upload.filesystem.upload_uri']" name="app.upload_uri" placeholder="/uploads"
:maxlength="200" required pattern="^\/(.+?)" />
</b-field>
</div><!-- filesystem -->
<div class="block" v-if="data['upload.provider'] === 's3'">
<b-field :label="$t('settings.media.s3.region')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.aws_default_region']" @input="onS3URLChange"
name="upload.s3.aws_default_region" :maxlength="200" placeholder="ap-south-1" />
</b-field>
<b-field :label="$t('settings.media.s3.key')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.aws_access_key_id']" name="upload.s3.aws_access_key_id" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.media.s3.secret')" label-position="on-border" expanded
message="Enter a value to change.">
<b-input v-model="data['upload.s3.aws_secret_access_key']" name="upload.s3.aws_secret_access_key"
type="password" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.media.s3.bucketType')" label-position="on-border">
<b-select v-model="data['upload.s3.bucket_type']" name="upload.s3.bucket_type" expanded>
<option value="private">
{{ $t('settings.media.s3.bucketTypePrivate') }}
</option>
<option value="public">
{{ $t('settings.media.s3.bucketTypePublic') }}
</option>
</b-select>
</b-field>
<b-field :label="$t('settings.media.s3.bucket')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.bucket']" @input="onS3URLChange" name="upload.s3.bucket" :maxlength="200"
placeholder="" />
</b-field>
<b-field :label="$t('settings.media.s3.bucketPath')" label-position="on-border"
:message="$t('settings.media.s3.bucketPathHelp')" expanded>
<b-input v-model="data['upload.s3.bucket_path']" name="upload.s3.bucket_path" :maxlength="200"
placeholder="/" />
</b-field>
<b-field :label="$t('settings.media.s3.uploadExpiry')" label-position="on-border"
:message="$t('settings.media.s3.uploadExpiryHelp')" expanded>
<b-input v-model="data['upload.s3.expiry']" name="upload.s3.expiry" placeholder="14d" :pattern="regDuration"
:maxlength="10" />
</b-field>
<b-field :label="$t('settings.media.s3.url')" label-position="on-border"
:message="$t('settings.media.s3.urlHelp')">
<b-input v-model="data['upload.s3.url']" name="upload.s3.url" required
placeholder="https://s3.$region.amazonaws.com" :maxlength="200" expanded type="url" pattern="https?://.*" />
</b-field>
<b-field :label="$t('settings.media.s3.publicURL')" label-position="on-border"
:message="$t('settings.media.s3.publicURLHelp')" expanded>
<b-input v-model="data['upload.s3.public_url']" name="upload.s3.public_url"
placeholder="https://files.yourdomain.com" :maxlength="200" type="string" pattern="(https?://.*|/.+)" />
</b-field>
</div><!-- s3 -->
</div>
</template>
<script>
import Vue from 'vue';
import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
regDuration,
extensions: [],
};
},
methods: {
onS3URLChange() {
// If a custom non-AWS URL has been entered, don't update it automatically.
if (this.data['upload.s3.url'] !== '' && !this.data['upload.s3.url'].match(/amazonaws\.com/)) {
return;
}
this.data['upload.s3.url'] = `https://s3.${this.data['upload.s3.aws_default_region']}.amazonaws.com`;
},
},
});
</script>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div>
<div class="items messengers">
<div class="block box" v-for="(item, n) in data.messengers" :key="n">
<b-field>
<b-switch v-model="item.enabled" name="enabled" :native-value="true">
{{ $t('globals.buttons.enabled') }}
</b-switch>
</b-field>
<b-field>
<a @click.prevent="$utils.confirm(null, () => removeMessenger(n))" href="#" class="is-size-7">
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
<div :class="{ disabled: !item.enabled }">
<b-field :label="$t('globals.fields.name')" label-position="on-border"
:message="$t('settings.messengers.nameHelp')">
<b-input v-model="item.name" name="name" placeholder="mymessenger" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.messengers.url')" label-position="on-border"
:message="$t('settings.messengers.urlHelp')">
<b-input v-model="item.root_url" name="root_url" placeholder="https://postback.messenger.net/path"
:maxlength="200" expanded type="url" pattern="https?://.*" />
</b-field>
<b-field :label="$t('settings.messengers.username')" label-position="on-border" expanded>
<b-input v-model="item.username" name="username" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.messengers.password')" label-position="on-border" expanded
:message="$t('globals.messages.passwordChange')">
<b-input v-model="item.password" name="password" type="password"
:placeholder="$t('globals.messages.passwordChange')" :maxlength="200" />
</b-field>
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.messengers.maxConns')" label-position="on-border"
:message="$t('settings.messengers.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.messengers.retries')" label-position="on-border"
:message="$t('settings.messengers.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries" type="is-light"
controls-position="compact" placeholder="2" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.messengers.timeout')" label-position="on-border"
:message="$t('settings.messengers.timeoutHelp')">
<b-input v-model="item.timeout" name="timeout" placeholder="5s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div>
</div>
</div><!-- block -->
</div><!-- mail-servers -->
<b-button @click="addMessenger" icon-left="plus" type="is-primary">
{{ $t('globals.buttons.addNew') }}
</b-button>
</div>
</template>
<script>
import Vue from 'vue';
import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
regDuration,
};
},
methods: {
addMessenger() {
this.data.messengers.push({
enabled: true,
root_url: '',
name: '',
username: '',
password: '',
max_conns: 25,
max_msg_retries: 2,
timeout: '5s',
});
this.$nextTick(() => {
const items = document.querySelectorAll('.messengers input[name="name"]');
items[items.length - 1].focus();
});
},
removeMessenger(i) {
this.data.messengers.splice(i, 1);
},
},
});
</script>
@@ -0,0 +1,98 @@
<template>
<div class="items">
<b-field :label="$t('settings.performance.concurrency')" label-position="on-border"
:message="$t('settings.performance.concurrencyHelp')">
<b-numberinput v-model="data['app.concurrency']" name="app.concurrency" type="is-light" placeholder="5" min="1"
max="10000" />
</b-field>
<b-field :label="$t('settings.performance.messageRate')" label-position="on-border"
:message="$t('settings.performance.messageRateHelp')">
<b-numberinput v-model="data['app.message_rate']" name="app.message_rate" type="is-light" placeholder="5" min="1"
max="100000" />
</b-field>
<b-field :label="$t('settings.performance.batchSize')" label-position="on-border"
:message="$t('settings.performance.batchSizeHelp')">
<b-numberinput v-model="data['app.batch_size']" name="app.batch_size" type="is-light" placeholder="1000" min="1"
max="100000" />
</b-field>
<b-field :label="$t('settings.performance.maxErrThreshold')" label-position="on-border"
:message="$t('settings.performance.maxErrThresholdHelp')">
<b-numberinput v-model="data['app.max_send_errors']" name="app.max_send_errors" type="is-light" placeholder="1999"
min="0" max="100000" />
</b-field>
<div>
<div class="columns">
<div class="column is-6">
<b-field :message="$t('settings.performance.slidingWindowHelp')">
<b-switch v-model="data['app.message_sliding_window']" name="app.message_sliding_window">
{{ $t('settings.performance.slidingWindow') }}
</b-switch>
</b-field>
</div>
<div class="column is-3" :class="{ disabled: !data['app.message_sliding_window'] }">
<b-field :label="$t('settings.performance.slidingWindowRate')" label-position="on-border"
:message="$t('settings.performance.slidingWindowRateHelp')">
<b-numberinput v-model="data['app.message_sliding_window_rate']" name="sliding_window_rate" type="is-light"
controls-position="compact" :disabled="!data['app.message_sliding_window']" placeholder="25" min="1"
max="10000000" />
</b-field>
</div>
<div class="column is-3" :class="{ disabled: !data['app.message_sliding_window'] }">
<b-field :label="$t('settings.performance.slidingWindowDuration')" label-position="on-border"
:message="$t('settings.performance.slidingWindowDurationHelp')">
<b-input v-model="data['app.message_sliding_window_duration']" name="sliding_window_duration"
:disabled="!data['app.message_sliding_window']" placeholder="1h" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
</div>
</div><!-- sliding window -->
<div>
<hr />
<div class="columns">
<div class="column is-4">
<b-field :message="$t('settings.performance.cacheSlowQueriesHelp')">
<b-switch v-model="data['app.cache_slow_queries']" name="app.cache_slow_queries">
{{ $t('settings.performance.cacheSlowQueries') }}
</b-switch>
</b-field>
</div>
<div class="column is-4" :class="{ disabled: !data['app.cache_slow_queries'] }">
<b-field :label="$t('settings.maintenance.cron')">
<b-input v-model="data['app.cache_slow_queries_interval']" :disabled="!data['app.cache_slow_queries']"
placeholder="0 3 * * *" />
</b-field>
</div>
<div class="column">
<br /><br />
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
regDuration,
};
},
});
</script>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="items">
<div class="columns">
<div class="column is-6">
<b-field :message="$t('settings.privacy.disableTrackingHelp')">
<b-switch v-model="data['privacy.disable_tracking']" name="privacy.disable_tracking">
{{ $t('settings.privacy.disableTracking') }}
</b-switch>
</b-field>
</div>
<div class="column is-6" :class="{ 'is-disabled': data['privacy.disable_tracking'] }">
<b-field :message="$t('settings.privacy.individualSubTrackingHelp')">
<b-switch v-model="data['privacy.individual_tracking']" :disabled="data['privacy.disable_tracking']"
name="privacy.individual_tracking">
{{ $t('settings.privacy.individualSubTracking') }}
</b-switch>
</b-field>
</div>
</div>
<b-field :message="$t('settings.privacy.listUnsubHeaderHelp')">
<b-switch v-model="data['privacy.unsubscribe_header']" name="privacy.unsubscribe_header">
{{ $t('settings.privacy.listUnsubHeader') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.privacy.allowBlocklistHelp')">
<b-switch v-model="data['privacy.allow_blocklist']" name="privacy.allow_blocklist">
{{ $t('settings.privacy.allowBlocklist') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.privacy.allowPrefsHelp')">
<b-switch v-model="data['privacy.allow_preferences']" name="privacy.allow_blocklist">
{{ $t('settings.privacy.allowPrefs') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.privacy.allowExportHelp')">
<b-switch v-model="data['privacy.allow_export']" name="privacy.allow_export">
{{ $t('settings.privacy.allowExport') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.privacy.allowWipeHelp')">
<b-switch v-model="data['privacy.allow_wipe']" name="privacy.allow_wipe">
{{ $t('settings.privacy.allowWipe') }}
</b-switch>
</b-field>
<b-field :message="$t('settings.privacy.recordOptinIPHelp')">
<b-switch v-model="data['privacy.record_optin_ip']" name="privacy.record_optin_ip">
{{ $t('settings.privacy.recordOptinIP') }}
</b-switch>
</b-field>
<hr />
<b-tabs v-model="tab" type="is-boxed" :animated="false">
<b-tab-item :label="`${$t('settings.privacy.domainBlocklist')} (${numBlocked})`">
<b-field :message="$t('settings.privacy.domainBlocklistHelp')">
<b-input type="textarea" v-model="data['privacy.domain_blocklist']" name="privacy.domain_blocklist" />
</b-field>
</b-tab-item>
<b-tab-item :label="`${$t('settings.privacy.domainAllowlist')} (${numAllowed})`">
<b-field :message="$t('settings.privacy.domainAllowlistHelp')">
<b-input type="textarea" v-model="data['privacy.domain_allowlist']" name="privacy.domain_allowlist" />
</b-field>
</b-tab-item>
</b-tabs>
</div>
</template>
<script>
import Vue from 'vue';
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
tab: 0,
};
},
methods: {
countItems(str) {
return str.split('\n').filter((line) => line.trim()).length;
},
},
mounted() {
this.tab = this.$utils.getPref('settings.privacyDomainTab') || 0;
},
computed: {
numBlocked() {
return this.countItems(this.form['privacy.domain_blocklist']);
},
numAllowed() {
return this.countItems(this.form['privacy.domain_allowlist']);
},
},
watch: {
tab(t) {
this.$utils.setPref('settings.privacyDomainTab', t);
},
},
});
</script>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="items">
<div class="columns">
<div class="column is-3">
<b-field :message="$t('settings.security.OIDCHelp')">
<b-switch v-model="data['security.oidc']['enabled']" name="security.oidc">
{{ $t('settings.security.enableOIDC') }}
</b-switch>
</b-field>
</div>
<div class="column is-9">
<b-field :label="$t('settings.security.OIDCURL')" label-position="on-border">
<div>
<b-input v-model="data['security.oidc']['provider_url']" name="oidc.provider_url"
placeholder="https://login.yoursite.com" :disabled="!data['security.oidc']['enabled']" :maxlength="300"
required type="url" pattern="https?://.*" />
<div class="spaced-links is-size-7 mt-2" :class="{ 'disabled': !data['security.oidc']['enabled'] }">
<a href="#" @click.prevent="() => setProvider('google')">Google</a>
<a href="#" @click.prevent="() => setProvider('microsoft')">Microsoft</a>
<a href="#" @click.prevent="() => setProvider('apple')">Apple</a>
</div>
</div>
</b-field>
<b-field :label="$t('settings.security.OIDCName')" label-position="on-border">
<b-input v-model="data['security.oidc']['provider_name']" name="oidc.provider_name" ref="provider_name"
:disabled="!data['security.oidc']['enabled']" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.security.OIDCClientID')" label-position="on-border">
<b-input v-model="data['security.oidc']['client_id']" name="oidc.client_id" ref="client_id"
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.OIDCClientSecret')" label-position="on-border">
<b-input v-model="data['security.oidc']['client_secret']" name="oidc.client_secret" type="password"
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
</b-field>
<hr />
<b-field :message="$t('settings.security.OIDCAutoCreateUsersHelp')">
<b-switch v-model="data['security.oidc']['auto_create_users']" :disabled="!data['security.oidc']['enabled']"
name="oidc.auto_create_users">
{{ $t('settings.security.OIDCAutoCreateUsers') }}
</b-switch>
</b-field>
<b-field :label="$t('settings.security.OIDCDefaultUserRole')" label-position="on-border"
:message="$t('settings.security.OIDCDefaultRoleHelp')">
<b-select v-model="data['security.oidc']['default_user_role_id']"
:disabled="!data['security.oidc']['enabled'] || !data['security.oidc']['auto_create_users']"
name="oidc.default_user_role_id" expanded>
<option v-for="role in userRoles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</b-select>
</b-field>
<b-field :label="$t('settings.security.OIDCDefaultListRole')" label-position="on-border"
:message="$t('settings.security.OIDCDefaultRoleHelp')">
<b-select v-model="data['security.oidc']['default_list_role_id']"
:disabled="!data['security.oidc']['enabled'] || !data['security.oidc']['auto_create_users']"
name="oidc.default_list_role_id" expanded>
<option :value="null">&mdash; {{ $t("globals.terms.none") }} &mdash;</option>
<option v-for="role in listRoles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</b-select>
</b-field>
<hr />
<b-field :label="$t('settings.security.OIDCRedirectURL')">
<code><copy-text :text="`${serverConfig.root_url}/auth/oidc`" /></code>
</b-field>
<p v-if="data['security.oidc']['enabled'] && !isURLOk" class="has-text-danger">
<b-icon icon="warning-empty" />
{{ $t('settings.security.OIDCRedirectWarning') }}
</p>
</div>
</div>
<hr />
<div class="columns">
<div class="column is-3">
<b-field :message="$t('settings.security.enableCaptchaHelp')">
<b-switch v-model="captchaEnabled" name="security.captcha">
{{ $t('settings.security.enableCaptcha') }}
</b-switch>
</b-field>
</div>
<div class="column is-9" v-if="captchaEnabled">
<b-field>
<b-radio v-model="selectedProvider" native-value="altcha" name="captcha_provider">
ALTCHA
</b-radio>
<b-radio v-model="selectedProvider" native-value="hcaptcha" name="captcha_provider">
hCaptcha (deprecated)
</b-radio>
</b-field>
<!-- captcha settings -->
<div v-if="selectedProvider === 'altcha'">
<b-field :label="$t('settings.security.altchaComplexity')" label-position="on-border"
:message="$t('settings.security.altchaComplexityHelp')">
<b-input v-model.number="data['security.captcha']['altcha']['complexity']" name="altcha_complexity"
type="number" min="1000" max="1000000" required />
</b-field>
</div>
<div v-if="selectedProvider === 'hcaptcha'">
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
:message="$t('settings.security.captchaKeyHelp')">
<b-input v-model="data['security.captcha']['hcaptcha']['key']" name="hcaptcha_key" :maxlength="200"
required />
</b-field>
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
<b-input v-model="data['security.captcha']['hcaptcha']['secret']" name="hcaptcha_secret" type="password"
:maxlength="200" required />
</b-field>
</div>
</div>
</div><!-- captcha -->
<hr />
<!-- CORS -->
<div class="columns">
<div class="column is-12">
<h3 class="is-size-6"><strong>{{ $t('settings.security.trustedURLs') }} / CORS</strong></h3><br />
<b-field label-position="on-border" :message="$t('settings.security.trustedURLsHelp')">
<b-input v-model="trustedURLs" name="trusted_urls" type="textarea" rows="5"
placeholder="https://example.com" />
</b-field>
</div>
</div><!-- cors -->
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../../components/CopyText.vue';
const OIDC_PROVIDERS = {
google: 'https://accounts.google.com',
github: 'https://token.actions.githubusercontent.com',
microsoft: 'https://login.microsoftonline.com/{TENANT_HERE}/v2.0',
apple: 'https://appleid.apple.com',
};
export default Vue.extend({
components: {
CopyText,
},
props: {
form: {
type: Object, default: () => { },
},
},
computed: {
...mapState(['serverConfig', 'userRoles', 'listRoles']),
trustedURLs: {
get() {
// Convert array to newline-separated string.
const domains = this.data['security.trusted_urls'];
return domains && Array.isArray(domains) ? domains.join('\n') : '';
},
set(value) {
this.$set(this.data, 'security.trusted_urls', value.split('\n'));
},
},
captchaEnabled: {
get() {
return this.data['security.captcha'].altcha.enabled || this.data['security.captcha'].hcaptcha.enabled;
},
set(value) {
this.data['security.captcha'].altcha.enabled = !!value;
this.data['security.captcha'].hcaptcha.enabled = false;
},
},
selectedProvider: {
get() {
if (this.data['security.captcha'].hcaptcha.enabled) {
return 'hcaptcha';
}
return 'altcha';
},
set(value) {
this.data['security.captcha'].hcaptcha.enabled = value === 'hcaptcha';
this.data['security.captcha'].altcha.enabled = value === 'altcha';
},
},
version() {
return import.meta.env.VUE_APP_VERSION;
},
isMobile() {
return this.windowWidth <= 768;
},
isURLOk() {
try {
const u = new URL(this.serverConfig.root_url);
return u.hostname !== 'localhost' && u.hostname !== '127.0.0.1';
} catch (e) {
return false;
}
},
},
mounted() {
if (this.$can('roles:get')) {
this.$api.getUserRoles();
this.$api.getListRoles();
}
},
methods: {
setProvider(provider) {
this.$set(this.data['security.oidc'], 'provider_url', OIDC_PROVIDERS[provider]);
this.$set(this.data['security.oidc'], 'provider_name', provider.charAt(0).toUpperCase() + provider.slice(1));
this.$nextTick(() => {
this.$refs.client_id.focus();
});
},
},
data() {
return {
data: this.form,
};
},
});
</script>
+402
View File
@@ -0,0 +1,402 @@
<template>
<div>
<div class="items mail-servers">
<div class="block box" v-for="(item, n) in form.smtp" :key="n">
<div class="columns">
<div class="column is-2">
<b-field>
<b-switch v-model="item.enabled" name="enabled" :native-value="true" data-cy="btn-enable-smtp">
{{ $t('globals.buttons.enabled') }}
</b-switch>
</b-field>
<b-field v-if="form.smtp.length > 1">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))" href="#" data-cy="btn-delete-smtp">
<b-icon icon="trash-can-outline" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
</div><!-- first column -->
<div class="column" :class="{ disabled: !item.enabled }">
<div class="columns">
<div class="column is-9">
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
:message="$t('settings.mailserver.hostHelp')">
<b-input v-model="item.host" name="host" placeholder="smtp.yourmailserver.net" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
:message="$t('settings.mailserver.portHelp')">
<b-numberinput v-model="item.port" name="port" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
</div><!-- host -->
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.mailserver.authProtocol')" label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol" expanded>
<option value="login">
LOGIN
</option>
<option value="cram">
CRAM
</option>
<option value="plain">
PLAIN
</option>
<option value="none">
None
</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.username')" label-position="on-border" expanded>
<b-input v-model="item.username" :custom-class="`smtp-username-${n}`"
:disabled="item.auth_protocol === 'none'" name="username" placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.mailserver.password')" label-position="on-border" expanded
:message="$t('settings.mailserver.passwordHelp')">
<b-input v-model="item.password" :disabled="item.auth_protocol === 'none'" name="password"
type="password" :custom-class="`password-${n}`"
:placeholder="$t('settings.mailserver.passwordHelp')" :maxlength="200" />
</b-field>
</b-field>
</div>
</div><!-- auth -->
<div class="spaced-links is-size-7">
<a href="#" @click.prevent="() => fillSettings(n, 'gmail')">Gmail</a>
<a href="#" @click.prevent="() => fillSettings(n, 'ses')">Amazon SES</a>
<a href="#" @click.prevent="() => fillSettings(n, 'azure')">Azure ACS</a>
<a href="#" @click.prevent="() => fillSettings(n, 'mailgun')">Mailgun</a>
<a href="#" @click.prevent="() => fillSettings(n, 'mailjet')">Mailjet</a>
<a href="#" @click.prevent="() => fillSettings(n, 'sendgrid')">Sendgrid</a>
<a href="#" @click.prevent="() => fillSettings(n, 'postmark')">Postmark</a>
<a href="#" @click.prevent="() => fillSettings(n, 'forwardemail')">Forward Email</a>
<a href="#" @click.prevent="() => fillSettings(n, 'lettermint')">Lettermint</a>
</div>
<hr />
<div class="columns">
<div class="column is-6">
<b-field :label="$t('settings.smtp.heloHost')" label-position="on-border"
:message="$t('settings.smtp.heloHostHelp')">
<b-input v-model="item.hello_hostname" name="hello_hostname" placeholder="" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.tls')" expanded :message="$t('settings.mailserver.tlsHelp')"
label-position="on-border">
<b-select v-model="item.tls_type" name="items.tls_type">
<option value="none">
{{ $t('globals.states.off') }}
</option>
<option value="STARTTLS">
STARTTLS
</option>
<option value="TLS">
SSL/TLS
</option>
</b-select>
</b-field>
<b-field expanded :message="$t('settings.mailserver.skipTLSHelp')">
<b-switch v-model="item.tls_skip_verify" :disabled="item.tls_type === 'none'"
name="item.tls_skip_verify">
{{ $t('settings.mailserver.skipTLS') }}
</b-switch>
</b-field>
</b-field>
</div>
</div><!-- TLS -->
<hr />
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.mailserver.maxConns')" label-position="on-border"
:message="$t('settings.mailserver.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.mailserver.idleTimeout')" label-position="on-border"
:message="$t('settings.mailserver.idleTimeoutHelp')">
<b-input v-model="item.idle_timeout" name="idle_timeout" placeholder="15s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.mailserver.waitTimeout')" label-position="on-border"
:message="$t('settings.mailserver.waitTimeoutHelp')">
<b-input v-model="item.wait_timeout" name="wait_timeout" placeholder="5s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.smtp.retries')" label-position="on-border"
:message="$t('settings.smtp.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries" type="is-light"
controls-position="compact" placeholder="2" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.smtp.retryDelay')" label-position="on-border"
:message="$t('settings.smtp.retryDelayHelp')">
<b-input v-model="item.msg_retry_delay" name="msg_retry_delay" placeholder="0s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div>
<hr />
<div class="columns">
<div class="column is-6">
<b-field :label="$t('globals.fields.name')" label-position="on-border"
:message="$t('settings.mailserver.nameHelp')">
<b-input v-model="item.name" name="name" placeholder="email-primary" :maxlength="100" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.smtp.fromAddresses')" label-position="on-border"
:message="$t('settings.smtp.fromAddressesHelp')">
<b-taginput v-model="item.from_addresses" name="from_addresses" ellipsis icon="tag-outline"
:before-adding="validateFromAddress" placeholder="user@example.com, anothersite.com" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column">
<p v-if="item.email_headers.length === 0 && !item.showHeaders">
<a href="#" @click.prevent="() => showSMTPHeaders(n)">
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
</p>
<b-field v-if="item.email_headers.length > 0 || item.showHeaders" label-position="on-border"
:message="$t('settings.smtp.customHeadersHelp')">
<b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
placeholder="[{&quot;X-Custom&quot;: &quot;value&quot;}, {&quot;X-Custom2&quot;: &quot;value&quot;}]" />
</b-field>
</div>
</div>
<hr />
<form @submit.prevent="() => doSMTPTest(item, n)">
<div class="columns">
<template v-if="smtpTestItem === n">
<div class="column is-5">
<strong>{{ $t('settings.general.fromEmail') }}</strong>
<br />
{{ settings['app.from_email'] }}
</div>
<div class="column is-4">
<b-field :label="$t('settings.smtp.toEmail')" label-position="on-border">
<b-input type="email" required v-model="testEmail" :ref="'testEmailTo'"
placeholder="email@site.com" :custom-class="`test-email-${n}`" />
</b-field>
</div>
</template>
<div class="column has-text-right">
<b-button v-if="smtpTestItem === n" class="is-primary" @click.prevent="() => doSMTPTest(item, n)">
{{ $t('settings.smtp.sendTest') }}
</b-button>
<a href="#" v-else class="is-primary" @click.prevent="showTestForm(n)">
<b-icon icon="rocket-launch-outline" /> {{ $t('settings.smtp.testConnection') }}
</a>
</div>
<div class="columns">
<div class="column" />
</div>
</div>
<div v-if="errMsg && smtpTestItem === n">
<b-field class="mt-4" type="is-danger">
<b-input v-model="errMsg" type="textarea" custom-class="has-text-danger is-size-6" readonly />
</b-field>
</div>
</form><!-- smtp test -->
</div>
</div><!-- second container column -->
</div><!-- block -->
</div><!-- mail-servers -->
<b-button @click="addSMTP" icon-left="plus" type="is-primary">
{{ $t('globals.buttons.addNew') }}
</b-button>
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import { regDuration } from '../../constants';
const smtpTemplates = {
gmail: {
host: 'smtp.gmail.com', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
ses: {
host: 'email-smtp.YOUR-REGION.amazonaws.com', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
azure: {
host: 'smtp.azurecomm.net', port: 587, auth_protocol: 'login', tls_type: 'STARTTLS',
},
mailjet: {
host: 'in-v3.mailjet.com', port: 465, auth_protocol: 'cram', tls_type: 'TLS',
},
mailgun: {
host: 'smtp.mailgun.org', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
sendgrid: {
host: 'smtp.sendgrid.net', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
forwardemail: {
host: 'smtp.forwardemail.net', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
postmark: {
host: 'smtp.postmarkapp.com', port: 587, auth_protocol: 'cram', tls_type: 'STARTTLS',
},
lettermint: {
host: 'smtp.lettermint.co', port: 465, auth_protocol: 'login', tls_type: 'TLS',
},
};
export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},
data() {
return {
data: this.form,
regDuration,
// Index of the SMTP block item in the array to show the
// test form in.
smtpTestItem: null,
testEmail: '',
errMsg: '',
};
},
methods: {
addSMTP() {
this.data.smtp.push({
name: '',
enabled: true,
host: '',
hello_hostname: '',
port: 587,
auth_protocol: 'none',
username: '',
password: '',
email_headers: [],
from_addresses: [],
max_conns: 10,
max_msg_retries: 2,
msg_retry_delay: '10ms',
idle_timeout: '15s',
wait_timeout: '5s',
tls_type: 'STARTTLS',
tls_skip_verify: false,
});
this.$nextTick(() => {
const items = document.querySelectorAll('.mail-servers input[name="host"]');
items[items.length - 1].focus();
});
},
removeSMTP(i) {
this.data.smtp.splice(i, 1);
},
showSMTPHeaders(i) {
const s = this.data.smtp[i];
s.showHeaders = true;
this.data.smtp.splice(i, 1, s);
},
testConnection() {
let em = this.settings['app.from_email'].replace('>', '').split('<');
if (em.length > 1) {
em = `<${em[em.length - 1]}>`;
}
},
doSMTPTest(item, n) {
if (!this.isTestEnabled(item)) {
this.$utils.toast(this.$t('settings.smtp.testEnterEmail'), 'is-danger');
this.$nextTick(() => {
const i = document.querySelector(`.password-${n}`);
this.data.smtp[n].password = '';
i.focus();
i.select();
});
return;
}
this.errMsg = '';
this.$api.testSMTP({ ...item, email: this.testEmail }).then(() => {
this.$utils.toast(this.$t('campaigns.testSent'));
}).catch((err) => {
if (err.response?.data?.message) {
this.errMsg = err.response.data.message;
}
});
},
showTestForm(n) {
this.smtpTestItem = n;
this.testItem = this.form.smtp[n];
this.errMsg = '';
this.$nextTick(() => {
document.querySelector(`.test-email-${n}`).focus();
});
},
isTestEnabled(item) {
if (!item.host || !item.port) {
return false;
}
if (item.auth_protocol !== 'none' && item.password.includes('•')) {
return false;
}
return true;
},
validateFromAddress(v) {
// Accept an e-mail address (user@example.com) or a domain (example.com).
return /^[^\s@]+(\.[^\s@]+)+$|^[^\s@]+@[^\s@]+(\.[^\s@]+)+$/.test(v);
},
fillSettings(n, key) {
this.data.smtp.splice(n, 1, {
...this.data.smtp[n],
...smtpTemplates[key],
username: '',
password: '',
hello_hostname: '',
tls_skip_verify: false,
});
this.$nextTick(() => {
document.querySelector(`.smtp-username-${n}`).focus();
});
},
},
computed: {
...mapState(['settings']),
},
});
</script>