mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
Add granular permissions and role management to backend and admin UI.
This commit is contained in:
parent
2bb4e19b74
commit
f57ac201ff
2
Makefile
2
Makefile
|
@ -21,7 +21,7 @@ FRONTEND_DEPS = \
|
|||
|
||||
BIN := listmonk
|
||||
STATIC := config.toml.sample \
|
||||
schema.sql queries.sql \
|
||||
schema.sql queries.sql permissions.json \
|
||||
static/public:/public \
|
||||
static/email-templates \
|
||||
frontend/dist:/admin \
|
||||
|
|
15
cmd/admin.go
15
cmd/admin.go
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
@ -11,12 +12,13 @@ import (
|
|||
)
|
||||
|
||||
type serverConfig struct {
|
||||
Messengers []string `json:"messengers"`
|
||||
Langs []i18nLang `json:"langs"`
|
||||
Lang string `json:"lang"`
|
||||
Update *AppUpdate `json:"update"`
|
||||
NeedsRestart bool `json:"needs_restart"`
|
||||
Version string `json:"version"`
|
||||
Messengers []string `json:"messengers"`
|
||||
Langs []i18nLang `json:"langs"`
|
||||
Lang string `json:"lang"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Update *AppUpdate `json:"update"`
|
||||
NeedsRestart bool `json:"needs_restart"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// handleGetServerConfig returns general server config.
|
||||
|
@ -34,6 +36,7 @@ func handleGetServerConfig(c echo.Context) error {
|
|||
}
|
||||
out.Langs = langList
|
||||
out.Lang = app.constants.Lang
|
||||
out.Permissions = app.constants.PermissionsRaw
|
||||
|
||||
// Sort messenger names with `email` always as the first item.
|
||||
var names []string
|
||||
|
|
26
cmd/init.go
26
cmd/init.go
|
@ -115,6 +115,9 @@ type constants struct {
|
|||
BounceSESEnabled bool
|
||||
BounceSendgridEnabled bool
|
||||
BouncePostmarkEnabled bool
|
||||
|
||||
PermissionsRaw json.RawMessage
|
||||
Permissions map[string]struct{}
|
||||
}
|
||||
|
||||
type notifTpls struct {
|
||||
|
@ -176,6 +179,7 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem
|
|||
"./config.toml.sample:config.toml.sample",
|
||||
"./queries.sql:queries.sql",
|
||||
"./schema.sql:schema.sql",
|
||||
"./permissions.json:permissions.json",
|
||||
}
|
||||
|
||||
frontendFiles = []string{
|
||||
|
@ -430,6 +434,28 @@ func initConstants() *constants {
|
|||
b := md5.Sum([]byte(time.Now().String()))
|
||||
c.AssetVersion = fmt.Sprintf("%x", b)[0:10]
|
||||
|
||||
pm, err := fs.Read("/permissions.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading permissions file: %v", err)
|
||||
}
|
||||
c.PermissionsRaw = pm
|
||||
|
||||
// Make a lookup map of permissions.
|
||||
permGroups := []struct {
|
||||
Group string `json:"group"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}{}
|
||||
if err := json.Unmarshal(pm, &permGroups); err != nil {
|
||||
lo.Fatalf("error loading permissions file: %v", err)
|
||||
}
|
||||
|
||||
c.Permissions = map[string]struct{}{}
|
||||
for _, group := range permGroups {
|
||||
for _, g := range group.Permissions {
|
||||
c.Permissions[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
|
|
113
cmd/roles.go
Normal file
113
cmd/roles.go
Normal file
|
@ -0,0 +1,113 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleGetRoles retrieves roles.
|
||||
func handleGetRoles(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
// Get all roles.
|
||||
out, err := app.core.GetRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleCreateRole handles role creation.
|
||||
func handleCreateRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
r = models.Role{}
|
||||
)
|
||||
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validatePerms(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := app.core.CreateRole(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleUpdateRole handles role modification.
|
||||
func handleUpdateRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Incoming params.
|
||||
var r models.Role
|
||||
if err := c.Bind(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validatePerms(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate.
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateRole(id, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleDeleteRole handles role deletion.
|
||||
func handleDeleteRole(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
if err := app.core.DeleteRole(int(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validatePerms(r models.Role, app *App) error {
|
||||
for _, p := range r.Permissions {
|
||||
if _, ok := app.constants.Permissions[p]; !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "permission"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -488,3 +488,25 @@ export const updateUserProfile = (data) => http.put(
|
|||
data,
|
||||
{ loading: models.users },
|
||||
);
|
||||
|
||||
export const getRoles = async () => http.get(
|
||||
'/api/roles',
|
||||
{ loading: models.roles, store: models.roles },
|
||||
);
|
||||
|
||||
export const createRole = (data) => http.post(
|
||||
'/api/roles',
|
||||
data,
|
||||
{ loading: models.roles },
|
||||
);
|
||||
|
||||
export const updateRole = (data) => http.put(
|
||||
`/api/roles/${data.id}`,
|
||||
data,
|
||||
{ loading: models.roles },
|
||||
);
|
||||
|
||||
export const deleteRole = (id) => http.delete(
|
||||
`/api/roles/${id}`,
|
||||
{ loading: models.roles },
|
||||
);
|
||||
|
|
|
@ -931,6 +931,22 @@ section.users {
|
|||
color: $green;
|
||||
}
|
||||
|
||||
.permissions-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
label {
|
||||
flex: 1 1 45%;
|
||||
max-width: 45%;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
th.role-toggle-select a {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* C3 charting lib */
|
||||
.c3 {
|
||||
.c3-text.c3-empty {
|
||||
|
|
|
@ -38,10 +38,16 @@
|
|||
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
|
||||
</b-menu-item><!-- campaigns -->
|
||||
|
||||
<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 :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>
|
||||
|
||||
<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"
|
||||
|
|
|
@ -9,6 +9,7 @@ export const models = Object.freeze({
|
|||
media: 'media',
|
||||
bounces: 'bounces',
|
||||
users: 'users',
|
||||
roles: 'roles',
|
||||
settings: 'settings',
|
||||
logs: 'logs',
|
||||
maintenance: 'maintenance',
|
||||
|
|
|
@ -114,11 +114,17 @@ const routes = [
|
|||
component: () => import('../views/Logs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings/users',
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
meta: { title: 'globals.terms.users', group: 'settings' },
|
||||
meta: { title: 'globals.terms.users', group: 'users' },
|
||||
component: () => import('../views/Users.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users/roles',
|
||||
name: 'roles',
|
||||
meta: { title: 'users.roles', group: 'users' },
|
||||
component: () => import('../views/Roles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings/maintenance',
|
||||
name: 'maintenance',
|
||||
|
|
|
@ -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.roles]: (state) => state[models.roles],
|
||||
[models.settings]: (state) => state[models.settings],
|
||||
[models.serverConfig]: (state) => state[models.serverConfig],
|
||||
[models.logs]: (state) => state[models.logs],
|
||||
|
|
156
frontend/src/views/RoleForm.vue
Normal file
156
frontend/src/views/RoleForm.vue
Normal file
|
@ -0,0 +1,156 @@
|
|||
<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.newRole') }}
|
||||
</h4>
|
||||
</header>
|
||||
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
|
||||
</b-field>
|
||||
|
||||
<p class="has-text-right">
|
||||
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
|
||||
</p>
|
||||
|
||||
<b-table :data="serverConfig.permissions">
|
||||
<b-table-column v-slot="props" field="group" label="Group">
|
||||
{{ $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[p]">
|
||||
{{ p }}
|
||||
</b-checkbox>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</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" :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 },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
permissions: {},
|
||||
},
|
||||
hasToggle: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
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[p] = true;
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
this.hasToggle = !this.hasToggle;
|
||||
},
|
||||
|
||||
createRole() {
|
||||
const form = { ...this.form, permissions: Object.keys(this.form.permissions) };
|
||||
this.$api.createRole(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
|
||||
updateRole() {
|
||||
const form = { id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions) };
|
||||
this.$api.updateRole(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'serverConfig']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.form, name: this.$props.data.name };
|
||||
|
||||
if (this.isEditing) {
|
||||
this.form.permissions = this.$props.data.permissions.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
} 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') {
|
||||
acc[p] = true;
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
134
frontend/src/views/Roles.vue
Normal file
134
frontend/src/views/Roles.vue
Normal file
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<section class="roles">
|
||||
<header class="columns page-header">
|
||||
<div class="column is-10">
|
||||
<h1 class="title is-4">
|
||||
{{ $t('users.roles') }}
|
||||
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field 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="roles" :loading="loading.roles" hoverable>
|
||||
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
|
||||
<a href="#" @click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
</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" align="right">
|
||||
<div>
|
||||
<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="deleteRole(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="700" @close="onFormClose">
|
||||
<role-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 RoleForm from './RoleForm.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
RoleForm,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
curItem: null,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 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.$api.getRoles();
|
||||
},
|
||||
|
||||
onFormClose() {
|
||||
if (this.$route.params.id) {
|
||||
this.$router.push({ name: 'users' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteRole(item) {
|
||||
this.$utils.confirm(
|
||||
this.$t('globals.messages.confirm'),
|
||||
() => {
|
||||
this.$api.deleteRole(item.id).then(() => {
|
||||
this.$api.getRoles();
|
||||
|
||||
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading', 'roles']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getRoles();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -130,6 +130,7 @@
|
|||
"forms.title": "Forms",
|
||||
"globals.buttons.add": "Add",
|
||||
"globals.buttons.addNew": "Add new",
|
||||
"globals.buttons.toggleSelect": "Toggle selection",
|
||||
"globals.buttons.back": "Back",
|
||||
"globals.buttons.cancel": "Cancel",
|
||||
"globals.buttons.clear": "Clear",
|
||||
|
@ -206,6 +207,7 @@
|
|||
"globals.months.9": "Sep",
|
||||
"globals.states.off": "Off",
|
||||
"globals.terms.all": "All",
|
||||
"globals.terms.admin": "Admin",
|
||||
"globals.terms.analytics": "Analytics",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
|
@ -595,6 +597,9 @@
|
|||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.role": "Role | Roles",
|
||||
"users.roles": "Roles",
|
||||
"users.newRole": "New role",
|
||||
"users.loginOIDC": "Login with {name}",
|
||||
"users.logout": "Logout",
|
||||
"users.profile": "Profile",
|
||||
|
|
|
@ -234,6 +234,12 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSession creates and sets a session (post successful login/auth).
|
||||
func (o *Auth) SetSession(u models.User, oidcToken string, c echo.Context) error {
|
||||
// sess, err := o.sess.Acquire(nil, c, c)
|
||||
|
|
58
internal/core/roles.go
Normal file
58
internal/core/roles.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetRoles retrieves all roles.
|
||||
func (c *Core) GetRoles() ([]models.Role, error) {
|
||||
out := []models.Role{}
|
||||
if err := c.q.GetRoles.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateRole creates a new role.
|
||||
func (c *Core) CreateRole(r models.Role) (models.Role, error) {
|
||||
var out models.Role
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, pq.Array(r.Permissions)); err != nil {
|
||||
return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateRole updates a given role.
|
||||
func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
|
||||
var out models.Role
|
||||
|
||||
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil {
|
||||
return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if out.ID == 0 {
|
||||
return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}"))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a given role.
|
||||
func (c *Core) DeleteRole(id int) error {
|
||||
if _, err := c.q.DeleteRole.Exec(id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -37,6 +37,15 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON roles(LOWER(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
data jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
|
|
|
@ -165,6 +165,13 @@ type User struct {
|
|||
HasPassword bool `db:"-" json:"-"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
Base
|
||||
|
||||
Name string `db:"name" json:"name"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
}
|
||||
|
||||
// Subscriber represents an e-mail subscriber.
|
||||
type Subscriber struct {
|
||||
Base
|
||||
|
|
|
@ -116,6 +116,11 @@ type Queries struct {
|
|||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
|
||||
LoginUser *sqlx.Stmt `query:"login-user"`
|
||||
|
||||
CreateRole *sqlx.Stmt `query:"create-role"`
|
||||
GetRoles *sqlx.Stmt `query:"get-roles"`
|
||||
UpdateRole *sqlx.Stmt `query:"update-role"`
|
||||
DeleteRole *sqlx.Stmt `query:"delete-role"`
|
||||
}
|
||||
|
||||
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions
|
||||
|
|
74
permissions.json
Normal file
74
permissions.json
Normal file
|
@ -0,0 +1,74 @@
|
|||
[
|
||||
{
|
||||
"group": "lists",
|
||||
"permissions":
|
||||
[
|
||||
"lists:get",
|
||||
"lists:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "subscribers",
|
||||
"permissions":
|
||||
[
|
||||
"subscribers:get",
|
||||
"subscribers:manage",
|
||||
"subscribers:import",
|
||||
"subscribers:sql_query",
|
||||
"tx:send"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "campaigns",
|
||||
"permissions":
|
||||
[
|
||||
"campaigns:get",
|
||||
"campaigns:get_analytics",
|
||||
"campaigns:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "bounces",
|
||||
"permissions":
|
||||
[
|
||||
"bounces:get",
|
||||
"bounces:manage",
|
||||
"webhooks:post_bounce"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "media",
|
||||
"permissions":
|
||||
[
|
||||
"media:get",
|
||||
"media:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "templates",
|
||||
"permissions":
|
||||
[
|
||||
"templates:get",
|
||||
"templates:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "users",
|
||||
"permissions":
|
||||
[
|
||||
"users:get",
|
||||
"users:manage",
|
||||
"roles:get",
|
||||
"roles:manage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "admin",
|
||||
"permissions":
|
||||
[
|
||||
"settings:get",
|
||||
"settings:manage",
|
||||
"maintenance:manage"
|
||||
]
|
||||
}
|
||||
]
|
12
queries.sql
12
queries.sql
|
@ -1083,3 +1083,15 @@ SELECT * FROM u WHERE CRYPT($2, password) = password;
|
|||
UPDATE users SET name=$2, email=$3,
|
||||
password=(CASE WHEN $4 = TRUE THEN (CASE WHEN $5 != '' THEN CRYPT($5, GEN_SALT('bf')) ELSE password END) ELSE NULL END)
|
||||
WHERE id=$1;
|
||||
|
||||
-- name: get-roles
|
||||
SELECT * FROM roles ORDER BY created_at;
|
||||
|
||||
-- name: create-role
|
||||
INSERT INTO roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
|
||||
|
||||
-- name: update-role
|
||||
UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *;
|
||||
|
||||
-- name: delete-role
|
||||
DELETE FROM roles WHERE id=$1;
|
||||
|
|
10
schema.sql
10
schema.sql
|
@ -316,6 +316,16 @@ CREATE TABLE users (
|
|||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS roles CASCADE;
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
DROP INDEX IF EXISTS idx_roles_name; CREATE UNIQUE INDEX idx_roles_name ON roles(LOWER(name));
|
||||
|
||||
-- user sessions
|
||||
DROP TABLE IF EXISTS sessions CASCADE;
|
||||
CREATE TABLE sessions (
|
||||
|
|
Loading…
Reference in a new issue