Skip to content

Commit

Permalink
Add permission checks to admin UI to toggle visibility/functionality …
Browse files Browse the repository at this point in the history
…of components.
  • Loading branch information
knadh committed Oct 13, 2024
1 parent dd9612b commit 474f935
Show file tree
Hide file tree
Showing 20 changed files with 217 additions and 175 deletions.
12 changes: 5 additions & 7 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@toggleGroup="toggleGroup" @doLogout="doLogout" />

<b-navbar-dropdown v-else>
<template v-if="profile" #label>
<template v-if="profile.username" #label>
<div class="user-avatar">
<img v-if="profile.avatar" :src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
Expand Down Expand Up @@ -87,7 +87,6 @@ export default Vue.extend({
data() {
return {
profile: null,
activeItem: {},
activeGroup: {},
windowWidth: window.innerWidth,
Expand Down Expand Up @@ -155,7 +154,7 @@ export default Vue.extend({
},
computed: {
...mapState(['serverConfig']),
...mapState(['serverConfig', 'profile']),
version() {
return import.meta.env.VUE_APP_VERSION;
Expand All @@ -169,16 +168,15 @@ export default Vue.extend({
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' });
if (this.$can('lists:get')) {
this.$api.getLists({ minimal: true, per_page: 'all' });
}
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth;
});
this.listenEvents();
this.$api.getUserProfile().then((d) => {
this.profile = d;
});
},
});
</script>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,13 @@ export const deleteUser = (id) => http.delete(

export const getUserProfile = () => http.get(
'/api/profile',
{ loading: models.users },
{ loading: models.users, store: models.profile },
);

export const updateUserProfile = (data) => http.put(
'/api/profile',
data,
{ loading: models.users },
{ loading: models.users, store: models.profile },
);

export const getRoles = async () => http.get(
Expand Down
82 changes: 47 additions & 35 deletions frontend/src/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<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"
<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"
:label="$t('globals.terms.lists')">
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"
Expand All @@ -12,55 +12,63 @@
icon="newspaper-variant-outline" :label="$t('menu.forms')" />
</b-menu-item><!-- lists -->

<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers" data-cy="subscribers"
@update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
<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 :to="{ name: 'subscribers' }" tag="router-link" :active="activeItem.subscribers"
data-cy="all-subscribers" icon="account-multiple" :label="$t('menu.allSubscribers')" />
<b-menu-item :to="{ name: 'import' }" tag="router-link" :active="activeItem.import" data-cy="import"
icon="file-upload-outline" :label="$t('menu.import')" />
<b-menu-item :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces" data-cy="bounces"
icon="email-bounce" :label="$t('globals.terms.bounces')" />
<b-menu-item v-if="$can('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 :expanded="activeGroup.campaigns" :active="activeGroup.campaigns" data-cy="campaigns"
@update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
<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 :to="{ name: 'campaigns' }" tag="router-link" :active="activeItem.campaigns" data-cy="all-campaigns"
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')" />
<b-menu-item :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 :to="{ name: 'media' }" tag="router-link" :active="activeItem.media" data-cy="media"
icon="image-outline" :label="$t('menu.media')" />
<b-menu-item :to="{ name: 'templates' }" tag="router-link" :active="activeItem.templates" data-cy="templates"
icon="file-image-outline" :label="$t('globals.terms.templates')" />
<b-menu-item :to="{ name: 'campaignAnalytics' }" tag="router-link" :active="activeItem.campaignAnalytics"
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
<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 :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 :to="{ name: 'users' }" tag="router-link" :active="activeItem.users" data-cy="users"
icon="account-multiple" :label="$t('globals.terms.users')" />
<b-menu-item :to="{ name: 'roles' }" tag="router-link" :active="activeItem.roles" data-cy="roles"
icon="newspaper-variant-outline" :label="$t('users.roles')" />
<b-menu-item v-if="$can('users:*') || $can('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: 'roles' }" tag="router-link" :active="activeItem.roles"
data-cy="roles" icon="newspaper-variant-outline" :label="$t('users.roles')" />
</b-menu-item>

<b-menu-item :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 :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings"
icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance"
data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs" data-cy="logs"
icon="newspaper-variant-outline" :label="$t('menu.logs')" />
<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="newspaper-variant-outline" :label="$t('menu.logs')" />
</b-menu-item><!-- settings -->

<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" />
</b-menu-list>
</template>

<script>
import { mapState } from 'vuex';
export default {
name: 'Navigation',
Expand All @@ -80,6 +88,10 @@ export default {
},
},
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.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const models = Object.freeze({
media: 'media',
bounces: 'bounces',
users: 'users',
profile: 'profile',
roles: 'roles',
settings: 'settings',
logs: 'logs',
Expand Down
57 changes: 36 additions & 21 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,43 @@ router.afterEach((to) => {
});
});

function initConfig(app) {
// Load server side config and language before mounting the app.
api.getServerConfig().then((data) => {
api.getLang(data.lang).then((lang) => {
i18n.locale = data.lang;
i18n.setLocaleMessage(i18n.locale, lang);

Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;

// Set the page title after i18n has loaded.
const to = router.history.current;
const t = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${t} listmonk`;

if (app) {
app.$mount('#app');
}
});
});
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;

// $can('permission:name') is used in the UI to chekc whether the logged in user
// 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({
Expand Down
1 change: 1 addition & 0 deletions frontend/src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default new Vuex.Store({
[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.roles]: (state) => state[models.roles],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
Expand Down
27 changes: 15 additions & 12 deletions frontend/src/views/Campaign.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</div>

<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 expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
Expand Down Expand Up @@ -113,7 +113,8 @@
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
: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>
</div>
</div>
Expand All @@ -140,7 +141,7 @@
</b-field>
</form>
</div>
<div class="column is-4 is-offset-1">
<div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">
Expand Down Expand Up @@ -175,14 +176,15 @@
</a>
</p>

<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border" expanded
data-cy="media">
<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">
<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>
<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">
Expand Down Expand Up @@ -212,8 +214,9 @@
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<a :href="`${settings['app.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>
Expand Down Expand Up @@ -245,8 +248,8 @@
</div>

<div class="column has-text-right">
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary" href="#"
@click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary"
href="#" @click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
</div>
</div>
<b-field>
Expand Down Expand Up @@ -596,7 +599,7 @@ export default Vue.extend({
},
computed: {
...mapState(['settings', 'loading', 'lists', 'templates']),
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
canEdit() {
return this.isNew
Expand Down Expand Up @@ -624,7 +627,7 @@ export default Vue.extend({
},
messengers() {
return ['email', ...this.settings.messengers.map((m) => m.name)];
return ['email', ...this.serverConfig.messengers.map((m) => m.name)];
},
},
Expand Down
Loading

0 comments on commit 474f935

Please sign in to comment.