Skip to content

Commit

Permalink
Add granular permissions and role management to backend and admin UI.
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Oct 13, 2024
1 parent 2000e9f commit d4e4c5f
Show file tree
Hide file tree
Showing 21 changed files with 681 additions and 11 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
15 changes: 9 additions & 6 deletions cmd/admin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"sort"
Expand All @@ -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.
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ type constants struct {
BounceSESEnabled bool
BounceSendgridEnabled bool
BouncePostmarkEnabled bool

PermissionsRaw json.RawMessage
Permissions map[string]struct{}
}

type notifTpls struct {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}

Expand Down
113 changes: 113 additions & 0 deletions cmd/roles.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
16 changes: 16 additions & 0 deletions frontend/src/assets/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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',
roles: 'roles',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
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.roles]: (state) => state[models.roles],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
[models.logs]: (state) => state[models.logs],
Expand Down
Loading

0 comments on commit d4e4c5f

Please sign in to comment.