Skip to content

Commit

Permalink
Add support for "list roles".
Browse files Browse the repository at this point in the history
This commit splits roles into two, user roles and list roles, both of which
are attached separately to a user.

List roles are collection of lists each with read|write permissions, while
user roles now have all permissions except for per-list ones.

This allows for easier management of roles, eliminating the need to clone and
create new roles just to adjust specific list permissions.
  • Loading branch information
knadh committed Oct 13, 2024
1 parent 12a6451 commit ae2a386
Show file tree
Hide file tree
Showing 26 changed files with 618 additions and 239 deletions.
9 changes: 6 additions & 3 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,12 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
api.DELETE("/api/users/:id", pm(handleDeleteUsers, "users:manage"))
api.POST("/api/logout", handleLogout)

api.GET("/api/roles", pm(handleGetRoles, "roles:get"))
api.POST("/api/roles", pm(handleCreateRole, "roles:manage"))
api.PUT("/api/roles/:id", pm(handleUpdateRole, "roles:manage"))
api.GET("/api/roles/users", pm(handleGetUserRoles, "roles:get"))
api.GET("/api/roles/lists", pm(handleGeListRoles, "roles:get"))
api.POST("/api/roles/users", pm(handleCreateUserRole, "roles:manage"))
api.POST("/api/roles/lists", pm(handleCreateListRole, "roles:manage"))
api.PUT("/api/roles/users/:id", pm(handleUpdateUserRole, "roles:manage"))
api.PUT("/api/roles/lists/:id", pm(handleUpdateListRole, "roles:manage"))
api.DELETE("/api/roles/:id", pm(handleDeleteRole, "roles:manage"))

if app.constants.BounceWebhooksEnabled {
Expand Down
2 changes: 1 addition & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,7 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth {
Status: models.UserStatusEnabled,
Type: models.UserTypeAPI,
}
u.Role.ID = auth.SuperAdminRoleID
u.UserRole.ID = auth.SuperAdminRoleID
a.CacheAPIUser(u)

lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`)
Expand Down
4 changes: 2 additions & 2 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func installUser(q *models.Queries) (string, string) {
perms = append(perms, p)
}

if _, err := q.CreateRole.Exec("Super Admin", pq.Array(perms)); err != nil {
if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil {
lo.Fatalf("error creating super admin role: %v", err)
}

Expand Down Expand Up @@ -146,7 +146,7 @@ func installUser(q *models.Queries) (string, string) {

lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)

if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil {
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, nil, "enabled"); err != nil {
lo.Fatalf("error creating superadmin user: %v", err)
}

Expand Down
106 changes: 95 additions & 11 deletions cmd/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
"github.com/labstack/echo/v4"
)

// handleGetRoles retrieves roles.
func handleGetRoles(c echo.Context) error {
// handleGetUserRoles retrieves roles.
func handleGetUserRoles(c echo.Context) error {
var (
app = c.Get("app").(*App)
)
Expand All @@ -25,8 +25,23 @@ func handleGetRoles(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}

// handleCreateRole handles role creation.
func handleCreateRole(c echo.Context) error {
// handleGeListRoles retrieves roles.
func handleGeListRoles(c echo.Context) error {
var (
app = c.Get("app").(*App)
)

// Get all roles.
out, err := app.core.GetListRoles()
if err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{out})
}

// handleCreateUserRole handles role creation.
func handleCreateUserRole(c echo.Context) error {
var (
app = c.Get("app").(*App)
r = models.Role{}
Expand All @@ -36,7 +51,7 @@ func handleCreateRole(c echo.Context) error {
return err
}

if err := validateRole(r, app); err != nil {
if err := validateUserRole(r, app); err != nil {
return err
}

Expand All @@ -48,8 +63,31 @@ func handleCreateRole(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}

// handleUpdateRole handles role modification.
func handleUpdateRole(c echo.Context) error {
// handleCreateListRole handles role creation.
func handleCreateListRole(c echo.Context) error {
var (
app = c.Get("app").(*App)
r = models.ListRole{}
)

if err := c.Bind(&r); err != nil {
return err
}

if err := validateListRole(r, app); err != nil {
return err
}

out, err := app.core.CreateListRole(r)
if err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{out})
}

// handleUpdateUserRole handles role modification.
func handleUpdateUserRole(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
Expand All @@ -65,14 +103,51 @@ func handleUpdateRole(c echo.Context) error {
return err
}

if err := validateRole(r, app); err != nil {
if err := validateUserRole(r, app); err != nil {
return err
}

// Validate.
r.Name.String = strings.TrimSpace(r.Name.String)

out, err := app.core.UpdateRole(id, r)
out, err := app.core.UpdateUserRole(id, r)
if err != nil {
return err
}

// Cache the API token for validating API queries without hitting the DB every time.
if err := cacheAPIUsers(app.core, app.auth); err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{out})
}

// handleUpdateListRole handles role modification.
func handleUpdateListRole(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)

if id < 2 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}

// Incoming params.
var r models.ListRole
if err := c.Bind(&r); err != nil {
return err
}

if err := validateListRole(r, app); err != nil {
return err
}

// Validate.
r.Name.String = strings.TrimSpace(r.Name.String)

out, err := app.core.UpdateListRole(id, r)
if err != nil {
return err
}
Expand Down Expand Up @@ -108,9 +183,9 @@ func handleDeleteRole(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}

func validateRole(r models.Role, app *App) error {
func validateUserRole(r models.Role, app *App) error {
// Validate fields.
if !strHasLen(r.Name.String, 2, stdInputMaxLen) {
if !strHasLen(r.Name.String, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
}

Expand All @@ -120,6 +195,15 @@ func validateRole(r models.Role, app *App) error {
}
}

return nil
}

func validateListRole(r models.ListRole, app *App) error {
// Validate fields.
if !strHasLen(r.Name.String, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
}

for _, l := range r.Lists {
for _, p := range l.Permissions {
if p != "list:get" && p != "list:manage" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
// hasSubPerm checks whether the current user has permission to access the given list
// of subscriber IDs.
func hasSubPerm(u models.User, subIDs []int, app *App) error {
if u.RoleID == auth.SuperAdminRoleID {
if u.UserRoleID == auth.SuperAdminRoleID {
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ func handleUpdateUserProfile(c echo.Context) error {
}
}

out, err := app.core.UpdateUser(user.ID, u)
out, err := app.core.UpdateUserProfile(user.ID, u)
if err != nil {
return err
}
Expand Down
37 changes: 27 additions & 10 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,24 +489,41 @@ export const updateUserProfile = (data) => http.put(
{ loading: models.users, store: models.profile },
);

export const getRoles = async () => http.get(
'/api/roles',
{ loading: models.roles, store: models.roles },
export const getUserRoles = async () => http.get(
'/api/roles/users',
{ loading: models.userRoles, store: models.userRoles },
);

export const createRole = (data) => http.post(
'/api/roles',
export const getListRoles = async () => http.get(
'/api/roles/lists',
{ loading: models.listRoles, store: models.listRoles },
);

export const createUserRole = (data) => http.post(
'/api/roles/users',
data,
{ loading: models.userRoles },
);

export const createListRole = (data) => http.post(
'/api/roles/lists',
data,
{ loading: models.listRoles },
);

export const updateUserRole = (data) => http.put(
`/api/roles/users/${data.id}`,
data,
{ loading: models.roles },
{ loading: models.userRoles },
);

export const updateRole = (data) => http.put(
`/api/roles/${data.id}`,
export const updateListRole = (data) => http.put(
`/api/roles/lists/${data.id}`,
data,
{ loading: models.roles },
{ loading: models.userRoles },
);

export const deleteRole = (id) => http.delete(
`/api/roles/${id}`,
{ loading: models.roles },
{ loading: models.userRoles },
);
15 changes: 11 additions & 4 deletions frontend/src/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,22 @@
:label="$t('globals.terms.analytics')" />
</b-menu-item><!-- campaigns -->

<b-menu-item v-if="$can('users:*', 'roles:*')" :expanded="activeGroup.users" :active="activeGroup.users"
data-cy="users" @update:active="(state) => toggleGroup('users', state)" icon="account-multiple"
:label="$t('globals.terms.users')">
<b-menu-item v-if="$can('users:get')" :to="{ name: 'users' }" tag="router-link" :active="activeItem.users"
data-cy="users" icon="account-multiple" :label="$t('globals.terms.users')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'userRoles' }" tag="router-link" :active="activeItem.userRoles"
data-cy="userRoles" icon="newspaper-variant-outline" :label="$t('users.userRoles')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'listRoles' }" tag="router-link" :active="activeItem.listRoles"
data-cy="listRoles" icon="format-list-bulleted-square" :label="$t('users.listRoles')" />
</b-menu-item><!-- users -->

<b-menu-item v-if="$can('settings:*')" :expanded="activeGroup.settings" :active="activeGroup.settings"
data-cy="settings" @update:active="(state) => toggleGroup('settings', state)" icon="cog-outline"
:label="$t('menu.settings')">
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
:active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item v-if="$can('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 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"
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const models = Object.freeze({
bounces: 'bounces',
users: 'users',
profile: 'profile',
roles: 'roles',
userRoles: 'userRoles',
listRoles: 'listRoles',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function initConfig(app) {
// $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 = (...perms) => {
if (profile.role_id === 1) {
if (profile.userRole.id === 1) {
return true;
}

Expand All @@ -55,10 +55,10 @@ async function initConfig(app) {
return perms.some((perm) => {
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.role.permissions.some((p) => p.startsWith(group));
return profile.userRole.permissions.some((p) => p.startsWith(group));
}

return profile.role.permissions.includes(perm);
return profile.userRole.permissions.includes(perm);
});
};

Expand Down
16 changes: 11 additions & 5 deletions frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,21 @@ 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: '/settings/users/roles',
name: 'roles',
meta: { title: 'users.roles', group: 'settings' },
path: '/users/roles/users',
name: 'userRoles',
meta: { title: 'users.userRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
path: '/users/roles/lists',
name: 'listRoles',
meta: { title: 'users.listRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export default new Vuex.Store({
[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.userRoles]: (state) => state[models.userRoles],
[models.listRoles]: (state) => state[models.listRoles],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
[models.logs]: (state) => state[models.logs],
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/ListForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default Vue.extend({
return true;
}
const list = this.profile.role.lists.find((l) => l.id === this.$props.data.id);
const list = this.profile.userRole.lists.find((l) => l.id === this.$props.data.id);
return list && list.permissions.includes('list:manage');
},
},
Expand Down
Loading

0 comments on commit ae2a386

Please sign in to comment.