Add permission checks to admin UI to toggle visibility/functionality of components.

This commit is contained in:
Kailash Nadh 2024-06-17 16:25:24 +05:30
parent dd9612b1ed
commit 474f93559f
20 changed files with 214 additions and 172 deletions

View file

@ -14,7 +14,7 @@
@toggleGroup="toggleGroup" @doLogout="doLogout" /> @toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-dropdown v-else> <b-navbar-dropdown v-else>
<template v-if="profile" #label> <template v-if="profile.username" #label>
<div class="user-avatar"> <div class="user-avatar">
<img v-if="profile.avatar" :src="profile.avatar" alt="" /> <img v-if="profile.avatar" :src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span> <span v-else>{{ profile.username[0].toUpperCase() }}</span>
@ -87,7 +87,6 @@ export default Vue.extend({
data() { data() {
return { return {
profile: null,
activeItem: {}, activeItem: {},
activeGroup: {}, activeGroup: {},
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
@ -155,7 +154,7 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['serverConfig']), ...mapState(['serverConfig', 'profile']),
version() { version() {
return import.meta.env.VUE_APP_VERSION; return import.meta.env.VUE_APP_VERSION;
@ -169,16 +168,15 @@ export default Vue.extend({
mounted() { mounted() {
// Lists is required across different views. On app load, fetch the lists // Lists is required across different views. On app load, fetch the lists
// and have them in the store. // and have them in the store.
this.$api.getLists({ minimal: true, per_page: 'all' }); if (this.$can('lists:get')) {
this.$api.getLists({ minimal: true, per_page: 'all' });
}
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth; this.windowWidth = window.innerWidth;
}); });
this.listenEvents(); this.listenEvents();
this.$api.getUserProfile().then((d) => {
this.profile = d;
});
}, },
}); });
</script> </script>

View file

@ -480,13 +480,13 @@ export const deleteUser = (id) => http.delete(
export const getUserProfile = () => http.get( export const getUserProfile = () => http.get(
'/api/profile', '/api/profile',
{ loading: models.users }, { loading: models.users, store: models.profile },
); );
export const updateUserProfile = (data) => http.put( export const updateUserProfile = (data) => http.put(
'/api/profile', '/api/profile',
data, data,
{ loading: models.users }, { loading: models.users, store: models.profile },
); );
export const getRoles = async () => http.get( export const getRoles = async () => http.get(

View file

@ -3,7 +3,7 @@
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard" <b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard --> icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists" <b-menu-item v-if="$can('lists:get')" :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
@update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square" @update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')"> :label="$t('globals.terms.lists')">
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists" <b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"
@ -12,48 +12,54 @@
icon="newspaper-variant-outline" :label="$t('menu.forms')" /> icon="newspaper-variant-outline" :label="$t('menu.forms')" />
</b-menu-item><!-- lists --> </b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers" data-cy="subscribers" <b-menu-item v-if="$can('subscribers:*')" :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
@update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple" data-cy="subscribers" @update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
:label="$t('globals.terms.subscribers')"> :label="$t('globals.terms.subscribers')">
<b-menu-item :to="{ name: 'subscribers' }" tag="router-link" :active="activeItem.subscribers" <b-menu-item v-if="$can('subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
data-cy="all-subscribers" icon="account-multiple" :label="$t('menu.allSubscribers')" /> :active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
<b-menu-item :to="{ name: 'import' }" tag="router-link" :active="activeItem.import" data-cy="import" :label="$t('menu.allSubscribers')" />
icon="file-upload-outline" :label="$t('menu.import')" /> <b-menu-item v-if="$can('subscribers:import')" :to="{ name: 'import' }" tag="router-link"
<b-menu-item :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces" data-cy="bounces" :active="activeItem.import" data-cy="import" icon="file-upload-outline" :label="$t('menu.import')" />
icon="email-bounce" :label="$t('globals.terms.bounces')" /> <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><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns" :active="activeGroup.campaigns" data-cy="campaigns" <b-menu-item v-if="$can('campaigns:*')" :expanded="activeGroup.campaigns" :active="activeGroup.campaigns"
@update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline" data-cy="campaigns" @update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
:label="$t('globals.terms.campaigns')"> :label="$t('globals.terms.campaigns')">
<b-menu-item :to="{ name: 'campaigns' }" tag="router-link" :active="activeItem.campaigns" data-cy="all-campaigns" <b-menu-item v-if="$can('campaigns:get')" :to="{ name: 'campaigns' }" tag="router-link"
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')" /> :active="activeItem.campaigns" data-cy="all-campaigns" icon="rocket-launch-outline"
<b-menu-item :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" :active="activeItem.campaign" :label="$t('menu.allCampaigns')" />
data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" /> <b-menu-item v-if="$can('campaigns:manage')" :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link"
<b-menu-item :to="{ name: 'media' }" tag="router-link" :active="activeItem.media" data-cy="media" :active="activeItem.campaign" data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
icon="image-outline" :label="$t('menu.media')" /> <b-menu-item v-if="$can('media:*')" :to="{ name: 'media' }" tag="router-link" :active="activeItem.media"
<b-menu-item :to="{ name: 'templates' }" tag="router-link" :active="activeItem.templates" data-cy="templates" data-cy="media" icon="image-outline" :label="$t('menu.media')" />
icon="file-image-outline" :label="$t('globals.terms.templates')" /> <b-menu-item v-if="$can('templates:get')" :to="{ name: 'templates' }" tag="router-link"
<b-menu-item :to="{ name: 'campaignAnalytics' }" tag="router-link" :active="activeItem.campaignAnalytics" :active="activeItem.templates" data-cy="templates" icon="file-image-outline"
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" /> :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><!-- campaigns -->
<b-menu-item :expanded="activeGroup.users" :active="activeGroup.users" data-cy="users" <b-menu-item v-if="$can('users:*') || $can('roles:*')" :expanded="activeGroup.users" :active="activeGroup.users"
@update:active="(state) => toggleGroup('users', state)" icon="account-multiple" :label="$t('globals.terms.users')"> data-cy="users" @update:active="(state) => toggleGroup('users', state)" icon="account-multiple"
<b-menu-item :to="{ name: 'users' }" tag="router-link" :active="activeItem.users" data-cy="users" :label="$t('globals.terms.users')">
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"
<b-menu-item :to="{ name: 'roles' }" tag="router-link" :active="activeItem.roles" data-cy="roles" data-cy="users" icon="account-multiple" :label="$t('globals.terms.users')" />
icon="newspaper-variant-outline" :label="$t('users.roles')" /> <b-menu-item v-if="$can('roles:get')" :to="{ name: 'roles' }" tag="router-link" :active="activeItem.roles"
data-cy="roles" icon="newspaper-variant-outline" :label="$t('users.roles')" />
</b-menu-item> </b-menu-item>
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings" <b-menu-item v-if="$can('settings:*')" :expanded="activeGroup.settings" :active="activeGroup.settings"
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')"> data-cy="settings" @update:active="(state) => toggleGroup('settings', state)" icon="cog-outline"
<b-menu-item :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings" :label="$t('menu.settings')">
icon="cog-outline" :label="$t('menu.settings')" /> <b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance" :active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" /> <b-menu-item v-if="$can('settings:maintain')" :to="{ name: 'maintenance' }" tag="router-link"
<b-menu-item :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs" data-cy="logs" :active="activeItem.maintenance" data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
icon="newspaper-variant-outline" :label="$t('menu.logs')" /> <b-menu-item v-if="$can('settings:get')" :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs"
data-cy="logs" icon="newspaper-variant-outline" :label="$t('menu.logs')" />
</b-menu-item><!-- settings --> </b-menu-item><!-- settings -->
<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" /> <b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" />
@ -61,6 +67,8 @@
</template> </template>
<script> <script>
import { mapState } from 'vuex';
export default { export default {
name: 'Navigation', name: 'Navigation',
@ -80,6 +88,10 @@ export default {
}, },
}, },
computed: {
...mapState(['profile']),
},
mounted() { mounted() {
// A hack to close the open accordion burger menu items on click. // A hack to close the open accordion burger menu items on click.
// Buefy does not have a way to do this. // Buefy does not have a way to do this.

View file

@ -9,6 +9,7 @@ export const models = Object.freeze({
media: 'media', media: 'media',
bounces: 'bounces', bounces: 'bounces',
users: 'users', users: 'users',
profile: 'profile',
roles: 'roles', roles: 'roles',
settings: 'settings', settings: 'settings',
logs: 'logs', logs: 'logs',

View file

@ -31,28 +31,43 @@ router.afterEach((to) => {
}); });
}); });
function initConfig(app) { async function initConfig(app) {
// Load server side config and language before mounting the app. // Load logged in user profile, server side config, and the language file before mounting the app.
api.getServerConfig().then((data) => { const [profile, cfg] = await Promise.all([api.getUserProfile(), api.getServerConfig()]);
api.getLang(data.lang).then((lang) => {
i18n.locale = data.lang;
i18n.setLocaleMessage(i18n.locale, lang);
Vue.prototype.$utils = new Utils(i18n); const lang = await api.getLang(cfg.lang);
Vue.prototype.$api = api; i18n.locale = cfg.lang;
i18n.setLocaleMessage(i18n.locale, lang);
// Set the page title after i18n has loaded. Vue.prototype.$utils = new Utils(i18n);
const to = router.history.current; Vue.prototype.$api = api;
const t = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${t} listmonk`;
if (app) { // $can('permission:name') is used in the UI to chekc whether the logged in user
app.$mount('#app'); // has a certain permission to toggle visibility of UI objects and UI functionality.
} Vue.prototype.$can = (perm) => {
}); if (profile.role_id === 1) {
}); return true;
}
api.getSettings(); // 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.
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.permissions.some((p) => p.startsWith(group));
}
return profile.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} listmonk`;
if (app) {
app.$mount('#app');
}
} }
const v = new Vue({ const v = new Vue({

View file

@ -42,6 +42,7 @@ export default new Vuex.Store({
[models.media]: (state) => state[models.media], [models.media]: (state) => state[models.media],
[models.templates]: (state) => state[models.templates], [models.templates]: (state) => state[models.templates],
[models.users]: (state) => state[models.users], [models.users]: (state) => state[models.users],
[models.profile]: (state) => state[models.profile],
[models.roles]: (state) => state[models.roles], [models.roles]: (state) => state[models.roles],
[models.settings]: (state) => state[models.settings], [models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig], [models.serverConfig]: (state) => state[models.serverConfig],

View file

@ -23,7 +23,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="buttons"> <div v-if="$can('campaigns:manage')" class="buttons">
<b-field grouped v-if="isEditing && canEdit"> <b-field grouped v-if="isEditing && canEdit">
<b-field expanded> <b-field expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary" <b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
@ -113,7 +113,8 @@
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''"> :message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit" <b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
:placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock" :placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime" horizontal-time-picker /> :timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime"
horizontal-time-picker />
</b-field> </b-field>
</div> </div>
</div> </div>
@ -140,7 +141,7 @@
</b-field> </b-field>
</form> </form>
</div> </div>
<div class="column is-4 is-offset-1"> <div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
<br /> <br />
<div class="box"> <div class="box">
<h3 class="title is-size-6"> <h3 class="title is-size-6">
@ -175,14 +176,15 @@
</a> </a>
</p> </p>
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border" expanded <b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border"
data-cy="media"> expanded data-cy="media">
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename" <b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename"
@focus="onOpenAttach" :disabled="!canEdit" /> @focus="onOpenAttach" :disabled="!canEdit" />
</b-field> </b-field>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank" rel="noopener noreferer"> <a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank"
rel="noopener noreferer">
<b-icon icon="code" /> {{ $t('campaigns.templatingRef') }}</a> <b-icon icon="code" /> {{ $t('campaigns.templatingRef') }}</a>
<span v-if="canEdit && form.content.contentType !== 'plain'" class="is-size-6 has-text-grey ml-6"> <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"> <a v-if="form.altbody === null" href="#" @click.prevent="onAddAltBody">
@ -212,8 +214,9 @@
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" /> <b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div> </div>
<div class="column is-12"> <div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer" <a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')"> rel="noopener noreferer" :class="{ 'has-text-grey-light': !form.archive }"
aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" /> <b-icon icon="link-variant" />
</a> </a>
</div> </div>
@ -245,8 +248,8 @@
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary" href="#" <a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary"
@click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a> href="#" @click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
</div> </div>
</div> </div>
<b-field> <b-field>
@ -596,7 +599,7 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['settings', 'loading', 'lists', 'templates']), ...mapState(['serverConfig', 'loading', 'lists', 'templates']),
canEdit() { canEdit() {
return this.isNew return this.isNew
@ -624,7 +627,7 @@ export default Vue.extend({
}, },
messengers() { messengers() {
return ['email', ...this.settings.messengers.map((m) => m.name)]; return ['email', ...this.serverConfig.messengers.map((m) => m.name)];
}, },
}, },

View file

@ -8,7 +8,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <b-field v-if="$can('campaigns:manage')" expanded>
<b-button expanded :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" class="btn-new" <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"> type="is-primary" icon-left="plus" data-cy="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
@ -170,51 +170,56 @@
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right"> <b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
<div> <div>
<!-- start / pause / resume / scheduled --> <!-- start / pause / resume / scheduled -->
<a v-if="canStart(props.row)" href="#" <template v-if="$can('campaigns:manage')">
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start" <a v-if="canStart(props.row)" href="#"
:aria-label="$t('campaigns.start')"> @click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
<b-tooltip :label="$t('campaigns.start')" type="is-dark"> data-cy="btn-start" :aria-label="$t('campaigns.start')">
<b-icon icon="rocket-launch-outline" size="is-small" /> <b-tooltip :label="$t('campaigns.start')" type="is-dark">
</b-tooltip> <b-icon icon="rocket-launch-outline" size="is-small" />
</a> </b-tooltip>
<a v-if="canPause(props.row)" href="#" </a>
@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="canPause(props.row)" href="#"
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled @click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
aria-label=" "> :aria-label="$t('campaigns.pause')">
<b-icon icon="rocket-launch-outline" size="is-small" /> <b-tooltip :label="$t('campaigns.pause')" type="is-dark">
</a> <b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canCancel(props.row)" href="#" <a v-if="canResume(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))" @click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')"> data-cy="btn-resume" :aria-label="$t('campaigns.send')">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark"> <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" /> <b-icon icon="cancel" size="is-small" />
</b-tooltip> </a>
</a> </template>
<a v-else href="#" data-disabled aria-label=" ">
<b-icon icon="cancel" size="is-small" />
</a>
<a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview" <a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview"
:aria-label="$t('campaigns.preview')"> :aria-label="$t('campaigns.preview')">
@ -222,7 +227,7 @@
<b-icon icon="file-find-outline" size="is-small" /> <b-icon icon="file-find-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'), <a v-if="$can('campaigns:manage')" href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{ {
placeholder: $t('globals.fields.name'), placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }), value: $t('campaigns.copyOf', { name: props.row.name }),
@ -232,12 +237,13 @@
<b-icon icon="file-multiple-outline" size="is-small" /> <b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<router-link :to="{ name: 'campaignAnalytics', query: { id: props.row.id } }"> <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-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
<b-icon icon="chart-bar" size="is-small" /> <b-icon icon="chart-bar" size="is-small" />
</b-tooltip> </b-tooltip>
</router-link> </router-link>
<a href="#" <a v-if="$can('campaigns:manage')" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))" @click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')"> data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />

View file

@ -58,7 +58,8 @@
<b-button @click="$parent.close()"> <b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }} {{ $t('globals.buttons.close') }}
</b-button> </b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save"> <b-button v-if="$can('lists:manage')" native-type="submit" type="is-primary" :loading="loading.lists"
data-cy="btn-save">
{{ $t('globals.buttons.save') }} {{ $t('globals.buttons.save') }}
</b-button> </b-button>
</footer> </footer>

View file

@ -8,7 +8,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <b-field v-if="$can('lists:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new"> <b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
</b-button> </b-button>
@ -25,7 +25,8 @@
<form @submit.prevent="getLists"> <form @submit.prevent="getLists">
<div> <div>
<b-field> <b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" /> <b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
data-cy="query" />
<p class="controls"> <p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" /> <b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p> </p>
@ -106,26 +107,28 @@
<b-table-column v-slot="props" cell-class="actions" align="right"> <b-table-column v-slot="props" cell-class="actions" align="right">
<div> <div>
<router-link :to="`/campaigns/new?list_id=${props.row.id}`" data-cy="btn-campaign"> <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-tooltip :label="$t('lists.sendCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" /> <b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</router-link> </router-link>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit" <a v-if="$can('lists:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')"> :aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" /> <b-icon icon="pencil-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<router-link :to="{ name: 'import', query: { list_id: props.row.id } }" data-cy="btn-import"> <router-link v-if="$can('lists:import')" :to="{ name: 'import', query: { list_id: props.row.id } }"
data-cy="btn-import">
<b-tooltip :label="$t('import.title')" type="is-dark"> <b-tooltip :label="$t('import.title')" type="is-dark">
<b-icon icon="file-upload-outline" size="is-small" /> <b-icon icon="file-upload-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</router-link> </router-link>
<a href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete" <a v-if="$can('lists:manage')" href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')"> :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />

View file

@ -141,7 +141,7 @@ export default Vue.extend({
}, {}); }, {});
// It's the superadmin role. Disable the form. // It's the superadmin role. Disable the form.
if (this.$props.data.id === 1) { if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
this.disabled = true; this.disabled = true;
} }
} else { } else {

View file

@ -8,7 +8,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <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"> <b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
</b-button> </b-button>
@ -37,31 +37,33 @@
</b-table-column> </b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right"> <b-table-column v-slot="props" cell-class="actions" align="right">
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'), <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 }), 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"> (name) => onCloneRole(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-icon icon="file-multiple-outline" size="is-small" /> <b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
</b-tooltip> <b-icon icon="file-multiple-outline" size="is-small" />
</a>
<template v-if="props.row.id !== 1">
<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> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="onDeleteRole(props.row)" data-cy="btn-delete" <template v-if="props.row.id !== 1">
:aria-label="$t('globals.buttons.delete')"> <a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark"> :aria-label="$t('globals.buttons.edit')">
<b-icon icon="trash-can-outline" size="is-small" /> <b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
</b-tooltip> <b-icon icon="pencil-outline" size="is-small" />
</a> </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> </template>
</b-table-column> </b-table-column>

View file

@ -10,7 +10,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <b-field v-if="$can('settings:manage')" expanded>
<b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline" <b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline"
native-type="submit" class="isSaveEnabled" data-cy="btn-save"> native-type="submit" class="isSaveEnabled" data-cy="btn-save">
{{ $t('globals.buttons.save') }} {{ $t('globals.buttons.save') }}

View file

@ -56,7 +56,7 @@
</b-checkbox> </b-checkbox>
</b-field> </b-field>
</div> </div>
<div class="column is-5 has-text-right" v-if="isEditing"> <div v-if="$can('subscribers:manage') && isEditing" class="column is-5 has-text-right">
<a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }"> <a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }">
<b-icon icon="email-outline" size="is-small" /> <b-icon icon="email-outline" size="is-small" />
{{ $t('subscribers.sendOptinConfirm') }}</a> {{ $t('subscribers.sendOptinConfirm') }}</a>
@ -147,7 +147,8 @@
<b-button @click="$parent.close()"> <b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }} {{ $t('globals.buttons.close') }}
</b-button> </b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.subscribers"> <b-button v-if="$can('subscribers:manage')" native-type="submit" type="is-primary"
:loading="loading.subscribers">
{{ $t('globals.buttons.save') }} {{ $t('globals.buttons.save') }}
</b-button> </b-button>
</footer> </footer>

View file

@ -13,7 +13,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <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"> <b-button expanded type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new" class="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
</b-button> </b-button>
@ -42,7 +42,8 @@
data-cy="query" /> data-cy="query" />
<span class="is-size-6 has-text-grey"> <span class="is-size-6 has-text-grey">
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }} {{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank" rel="noopener noreferrer"> <a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}. {{ $t('globals.buttons.learnMore') }}.
</a> </a>
</span> </span>
@ -69,8 +70,8 @@
</section><!-- control --> </section><!-- control -->
<br /> <br />
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck" @check="onTableCheck" <b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck"
:checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both" @check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
@page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage" @page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage"
:total="subscribers.total" hoverable checkable backend-sorting @sort="onSort"> :total="subscribers.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left> <template #top-left>
@ -157,14 +158,14 @@
<b-icon icon="cloud-download-outline" size="is-small" /> <b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)" data-cy="btn-edit" <a v-if="$can('subscribers:manage')" :href="`/subscribers/${props.row.id}`"
:aria-label="$t('globals.buttons.edit')"> @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-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" /> <b-icon icon="pencil-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete" <a v-if="$can('subscribers:manage')" href="#" @click.prevent="deleteSubscriber(props.row)"
:aria-label="$t('globals.buttons.delete')"> data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip> </b-tooltip>

View file

@ -93,7 +93,8 @@
<b-button @click="$parent.close()"> <b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }} {{ $t('globals.buttons.close') }}
</b-button> </b-button>
<b-button v-if="!apiToken" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save"> <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') }} {{ $t('globals.buttons.save') }}
</b-button> </b-button>
</footer> </footer>

View file

@ -6,9 +6,7 @@
@{{ form.username }} @{{ form.username }}
</h1> </h1>
<b-tag :class="{ [form.type]: form.status === 'enabled' }"> <b-tag>{{ form.roleName }}</b-tag>
{{ $t(`users.type.${form.type}`) }}
</b-tag>
<br /><br /><br /> <br /><br /><br />
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">

View file

@ -8,7 +8,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field expanded> <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"> <b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
</b-button> </b-button>
@ -91,14 +91,14 @@
<b-table-column v-slot="props" cell-class="actions" align="right"> <b-table-column v-slot="props" cell-class="actions" align="right">
<div> <div>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit" <a v-if="$can('users:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')"> :aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" /> <b-icon icon="pencil-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="deleteUser(props.row)" data-cy="btn-delete" <a v-if="$can('users:manage')" href="#" @click.prevent="deleteUser(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')"> :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />

View file

@ -207,7 +207,6 @@
"globals.months.9": "Sep", "globals.months.9": "Sep",
"globals.states.off": "Off", "globals.states.off": "Off",
"globals.terms.all": "All", "globals.terms.all": "All",
"globals.terms.admin": "Admin",
"globals.terms.analytics": "Analytics", "globals.terms.analytics": "Analytics",
"globals.terms.bounce": "Bounce | Bounces", "globals.terms.bounce": "Bounce | Bounces",
"globals.terms.bounces": "Bounces", "globals.terms.bounces": "Bounces",

View file

@ -63,12 +63,12 @@
] ]
}, },
{ {
"group": "admin", "group": "settings",
"permissions": "permissions":
[ [
"settings:get", "settings:get",
"settings:manage", "settings:manage",
"maintenance:manage" "settings:maintain"
] ]
} }
] ]