+
{{ $t('users.listPerms') }}
+
+
+
+
+
+
+
+
-
-
- {{ $tc(`globals.terms.${props.row.group}`) }}
-
-
-
-
-
- {{ p }}
-
+
+
+ {{
+ $t('globals.buttons.add')
+ }}
+
-
-
+
+
+ {{ $t('users.listPermsWarning') }}
+
+
+
+
+
+
+ {{ props.row.name }}
+
+
+
+
+
+ {{ $t('globals.buttons.view') }}
+
+
+ {{ $t('globals.buttons.manage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('users.perms') }}
+
+
+
+
+
+
+ {{ $tc(`globals.terms.${props.row.group}`) }}
+
+
+
+
+
+ {{ p }}
+
+
+
+
+
{{ $t('globals.buttons.learnMore') }}
@@ -73,7 +136,9 @@ export default Vue.extend({
return {
// Binds form input values.
form: {
- name: '',
+ curList: null,
+ lists: [],
+ name: this.$t('users.newRole'),
permissions: {},
},
hasToggle: false,
@@ -82,6 +147,18 @@ export default Vue.extend({
},
methods: {
+ onAddListPerm() {
+ const list = this.lists.results.find((l) => l.id === this.form.curList);
+ this.form.lists.push({ id: list.id, name: list.name, permissions: ["list:get", "list:manage"] });
+
+ this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
+ },
+
+ onDeleteListPerm(id) {
+ this.form.lists = this.form.lists.filter((p) => p.id !== id);
+ this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
+ },
+
onSubmit() {
if (this.isEditing) {
this.updateRole();
@@ -93,21 +170,26 @@ export default Vue.extend({
onToggleSelect() {
if (this.hasToggle) {
- this.form.permissions = {};
+ this.form.permissions = [];
} else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => {
- acc[p] = true;
+ acc.push(p);
});
return acc;
- }, {});
+ }, []);
}
this.hasToggle = !this.hasToggle;
},
createRole() {
- const form = { ...this.form, permissions: Object.keys(this.form.permissions) };
+ const lists = this.form.lists.reduce((acc, item) => {
+ acc.push({ id: item.id, permissions: item.permissions })
+ return acc;
+ }, []);
+
+ const form = { name: this.form.name, permissions: this.form.permissions, lists: lists };
this.$api.createRole(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@@ -116,9 +198,12 @@ export default Vue.extend({
},
updateRole() {
- const form = {
- id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions).filter((key) => this.form.permissions[key] === true),
- };
+ const lists = this.form.lists.reduce((acc, item) => {
+ acc.push({ id: item.id, permissions: item.permissions })
+ return acc;
+ }, []);
+
+ const form = { id: this.$props.data.id, name: this.form.name, permissions: this.form.permissions, lists: lists };
this.$api.updateRole(form).then((data) => {
this.$emit('finished');
this.$parent.close();
@@ -128,17 +213,23 @@ export default Vue.extend({
},
computed: {
- ...mapState(['loading', 'serverConfig']),
+ ...mapState(['loading', 'serverConfig', 'lists']),
+
+ // Return the list of unselected lists.
+ filteredLists() {
+ if (!this.lists.results) {
+ return [];
+ }
+
+ const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
+ return this.lists.results.filter((l) => (!(l.id in subIDs)));
+ },
+
},
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;
- }, {});
+ this.form = { ...this.form, ...this.$props.data };
// It's the superadmin role. Disable the form.
if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
@@ -151,15 +242,18 @@ export default Vue.extend({
return acc;
}
item.permissions.forEach((p) => {
- if (p !== 'subscribers:sql_query') {
- acc[p] = true;
+ if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
+ acc.push(p);
}
});
return acc;
- }, {});
+ }, []);
}
this.$nextTick(() => {
+ if (this.filteredLists.length > 0) {
+ this.form.curList = this.filteredLists[0].id;
+ }
this.$refs.focus.focus();
});
},
diff --git a/frontend/src/views/Roles.vue b/frontend/src/views/Roles.vue
index 0b10bacb..6d27643e 100644
--- a/frontend/src/views/Roles.vue
+++ b/frontend/src/views/Roles.vue
@@ -125,8 +125,9 @@ export default Vue.extend({
},
onCloneRole(name, item) {
- this.$api.createRole({ name, permissions: item.permissions }).then(() => {
+ this.$api.createRole({ name, permissions: item.permissions, lists: item.lists }).then(() => {
this.$api.getRoles();
+ this.$utils.toast(this.$t('globals.messages.created', { name }));
});
},
diff --git a/i18n/en.json b/i18n/en.json
index 048391ac..946d617e 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -152,6 +152,7 @@
"globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes",
"globals.buttons.view": "View",
+ "globals.buttons.manage": "Manage",
"globals.days.0": "Sun",
"globals.days.1": "Sun",
"globals.days.2": "Mon",
@@ -600,6 +601,10 @@
"users.role": "Role | Roles",
"users.roles": "Roles",
"users.newRole": "New role",
+ "users.listPerms": "List permissions",
+ "users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions",
+ "users.perms": "Permissions",
+ "users.roleGroup": "Group",
"users.loginOIDC": "Login with {name}",
"users.logout": "Logout",
"users.profile": "Profile",
diff --git a/internal/core/roles.go b/internal/core/roles.go
index 15838522..29dd9dac 100644
--- a/internal/core/roles.go
+++ b/internal/core/roles.go
@@ -1,6 +1,7 @@
package core
import (
+ "encoding/json"
"net/http"
"github.com/knadh/listmonk/models"
@@ -16,6 +17,17 @@ func (c *Core) GetRoles() ([]models.Role, error) {
c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err)))
}
+ // Unmarshall the nested list permissions, if any.
+ for n, r := range out {
+ if r.ListsRaw == nil {
+ continue
+ }
+
+ if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
+ c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
+ }
+ }
+
return out, nil
}
@@ -28,9 +40,55 @@ func (c *Core) CreateRole(r models.Role) (models.Role, error) {
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
+ if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
+ return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
+ }
+
return out, nil
}
+// UpsertListPermissions upserts permission for a role.
+func (c *Core) UpsertListPermissions(roleID int, lp []models.ListPermission) error {
+ var (
+ listIDs = make([]int, 0, len(lp))
+ listPerms = make([][]string, 0, len(lp))
+ )
+ for _, p := range lp {
+ if len(p.Permissions) == 0 {
+ continue
+ }
+
+ listIDs = append(listIDs, p.ID)
+
+ // For the Postgres array unnesting query to work, all permissions arrays should
+ // have equal number of entries. Add "" in case there's only one of either list:get or list:manage
+ perms := make([]string, 2)
+ copy(perms[:], p.Permissions[:])
+ listPerms = append(listPerms, perms)
+ }
+
+ if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
+ }
+
+ return nil
+}
+
+// DeleteListPermission deletes a list permission entry from a role.
+func (c *Core) DeleteListPermission(roleID, listID int) error {
+ if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
+ if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
+ return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
+ }
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
+ }
+
+ return nil
+}
+
// UpdateRole updates a given role.
func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
var out models.Role
@@ -44,6 +102,11 @@ func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}"))
}
+ if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
+ return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
+ }
+
return out, nil
}
diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go
index a74f6d9c..363ad2b4 100644
--- a/internal/migrations/v3.1.0.go
+++ b/internal/migrations/v3.1.0.go
@@ -41,12 +41,15 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
CREATE TABLE IF NOT EXISTS user_roles (
id SERIAL PRIMARY KEY,
- name TEXT NOT NULL DEFAULT '',
+ parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
+ name TEXT NULL,
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 user_roles(LOWER(name));
+ CREATE UNIQUE INDEX IF NOT EXISTS user_roles_idx ON user_roles (parent_id, list_id);
+ CREATE UNIQUE INDEX IF NOT EXISTS user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT NOT NULL PRIMARY KEY,
diff --git a/models/models.go b/models/models.go
index d9969127..23188d28 100644
--- a/models/models.go
+++ b/models/models.go
@@ -168,11 +168,22 @@ type User struct {
HasPassword bool `db:"-" json:"-"`
}
+type ListPermission struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Permissions pq.StringArray `json:"permissions"`
+}
+
type Role struct {
Base
- Name string `db:"name" json:"name"`
+ Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
+
+ ListID null.Int `db:"list_id" json:"-"`
+ ParentID null.Int `db:"parent_id" json:"-"`
+ ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
+ Lists []ListPermission `db:"-" json:"lists"`
}
// Subscriber represents an e-mail subscriber.
diff --git a/models/queries.go b/models/queries.go
index a079a299..2a52478d 100644
--- a/models/queries.go
+++ b/models/queries.go
@@ -117,10 +117,12 @@ type Queries struct {
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"`
+ CreateRole *sqlx.Stmt `query:"create-role"`
+ GetRoles *sqlx.Stmt `query:"get-roles"`
+ UpdateRole *sqlx.Stmt `query:"update-role"`
+ DeleteRole *sqlx.Stmt `query:"delete-role"`
+ UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
+ DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`
}
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions
diff --git a/permissions.json b/permissions.json
index 7f0e834d..52eecfbd 100644
--- a/permissions.json
+++ b/permissions.json
@@ -3,8 +3,8 @@
"group": "lists",
"permissions":
[
- "lists:get",
- "lists:manage"
+ "lists:get_all",
+ "lists:manage_all"
]
},
{
diff --git a/queries.sql b/queries.sql
index 8ab5058e..8188b3fd 100644
--- a/queries.sql
+++ b/queries.sql
@@ -1096,13 +1096,39 @@ UPDATE users SET name=$2, email=$3,
WHERE id=$1;
-- name: get-roles
-SELECT * FROM user_roles ORDER BY created_at;
+WITH mainroles AS (
+ SELECT ur.* FROM user_roles ur WHERE ur.parent_id IS NULL
+),
+listroles AS (
+ SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
+ FROM user_roles ur
+ LEFT JOIN lists ON(lists.id = ur.list_id)
+ WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
+)
+SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
+ LEFT JOIN listroles l ON p.id = l.parent_id;
-- name: create-role
INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
+-- name: upsert-list-permissions
+WITH d AS (
+ -- Delete lists that aren't included.
+ DELETE FROM user_roles WHERE parent_id = $1 AND list_id != ALL($2::INT[])
+),
+p AS (
+ -- Get (list_id, perms[]), (list_id, perms[])
+ SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms
+)
+INSERT INTO user_roles (parent_id, list_id, permissions)
+ SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), '') FROM p
+ ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions;
+
+-- name: delete-list-permission
+DELETE FROM user_roles WHERE parent_id=$1 AND list_id=$2;
+
-- name: update-role
-UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *;
+UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *;
-- name: delete-role
DELETE FROM user_roles WHERE id=$1;
diff --git a/schema.sql b/schema.sql
index 99500899..f0433d19 100644
--- a/schema.sql
+++ b/schema.sql
@@ -304,12 +304,15 @@ DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces(
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (
id SERIAL PRIMARY KEY,
- name TEXT NOT NULL DEFAULT '',
+ parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
+ name TEXT NULL,
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 user_roles(LOWER(name));
+CREATE UNIQUE INDEX user_roles_idx ON user_roles (parent_id, list_id);
+CREATE UNIQUE INDEX user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL;
-- users
DROP TABLE IF EXISTS users CASCADE;
@@ -326,8 +329,6 @@ CREATE TABLE users (
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-
- -- CONSTRAINT user_role_id FOREIGN KEY (role_id) REFERENCES user_roles (id) ON DELETE RESTRICT
);
-- user sessions