@@ -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') }}
|
||||
—
|
||||
<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>
|
||||
@@ -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
@@ -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 |
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }} </span>
|
||||
<span v-if="line.file !== '*'" class="file">{{ line.file }}: </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
—
|
||||
</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>
|
||||
@@ -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(/"/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>
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
},
|
||||
});
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '=',
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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">
|
||||
—
|
||||
<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>
|
||||
@@ -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="[{"X-Custom": "value"}, {"X-Custom2": "value"}]"
|
||||
: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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
—
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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() }} →
|
||||
</router-link>
|
||||
<router-link v-else :to="{ name: 'lists' }">
|
||||
{{ $t('globals.buttons.view') }} {{ $t('menu.allLists').toLowerCase() }} →
|
||||
</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">
|
||||
—
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
» {{ 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">
|
||||
—
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="">— {{ $t("globals.terms.none") }} —</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">— {{ $t("globals.terms.none") }} —</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>
|
||||
@@ -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="[{"X-Custom": "value"}, {"X-Custom2": "value"}]" />
|
||||
</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>
|
||||
Reference in New Issue
Block a user