mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
Add per-list permission management to roles.
This commit is contained in:
parent
19527f97eb
commit
612c1d6eac
16 changed files with 330 additions and 87 deletions
|
@ -16,8 +16,6 @@ import (
|
|||
|
||||
// install runs the first time setup of setting up the database.
|
||||
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) {
|
||||
consts := initConstants()
|
||||
|
||||
qMap := readQueries(queryFilePath, db, fs)
|
||||
|
||||
fmt.Println("")
|
||||
|
@ -63,6 +61,43 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
// Load the queries.
|
||||
q := prepareQueries(qMap, db, ko)
|
||||
|
||||
// Sample list.
|
||||
defList, optinList := installLists(q)
|
||||
|
||||
// Sample subscribers.
|
||||
installSubs(defList, optinList, q)
|
||||
|
||||
// Templates.
|
||||
campTplID, archiveTplID := installTemplates(q)
|
||||
|
||||
// Sample campaign.
|
||||
installCampaign(campTplID, archiveTplID, q)
|
||||
|
||||
// Super admin role.
|
||||
installUser(q)
|
||||
|
||||
lo.Printf("setup complete")
|
||||
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
|
||||
}
|
||||
|
||||
// installSchema executes the SQL schema and creates the necessary tables and types.
|
||||
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
q, err := fs.Read("/schema.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(q)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the current migration version.
|
||||
return recordMigrationVersion(curVer, db)
|
||||
}
|
||||
|
||||
func installUser(q *models.Queries) {
|
||||
consts := initConstants()
|
||||
|
||||
// Super admin role.
|
||||
perms := []string{}
|
||||
for p := range consts.Permissions {
|
||||
|
@ -84,8 +119,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil {
|
||||
lo.Fatalf("error creating superadmin user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sample list.
|
||||
func installLists(q *models.Queries) (int, int) {
|
||||
var (
|
||||
defList int
|
||||
optinList int
|
||||
|
@ -111,13 +147,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating list: %v", err)
|
||||
}
|
||||
|
||||
return defList, optinList
|
||||
}
|
||||
|
||||
func installSubs(defListID, optinListID int, q *models.Queries) {
|
||||
// Sample subscriber.
|
||||
if _, err := q.UpsertSubscriber.Exec(
|
||||
uuid.Must(uuid.NewV4()),
|
||||
"john@example.com",
|
||||
"John Doe",
|
||||
`{"type": "known", "good": true, "city": "Bengaluru"}`,
|
||||
pq.Int64Array{int64(defList)},
|
||||
pq.Int64Array{int64(defListID)},
|
||||
models.SubscriptionStatusUnconfirmed,
|
||||
true); err != nil {
|
||||
lo.Fatalf("Error creating subscriber: %v", err)
|
||||
|
@ -127,12 +167,14 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
"anon@example.com",
|
||||
"Anon Doe",
|
||||
`{"type": "unknown", "good": true, "city": "Bengaluru"}`,
|
||||
pq.Int64Array{int64(optinList)},
|
||||
pq.Int64Array{int64(optinListID)},
|
||||
models.SubscriptionStatusUnconfirmed,
|
||||
true); err != nil {
|
||||
lo.Fatalf("error creating subscriber: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installTemplates(q *models.Queries) (int, int) {
|
||||
// Default campaign template.
|
||||
campTpl, err := fs.Get("/static/email-templates/default.tpl")
|
||||
if err != nil {
|
||||
|
@ -158,6 +200,20 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating default campaign template: %v", err)
|
||||
}
|
||||
|
||||
// Sample tx template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||
}
|
||||
|
||||
return campTplID, archiveTplID
|
||||
}
|
||||
|
||||
func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
|
||||
// Sample campaign.
|
||||
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
|
||||
models.CampaignTypeRegular,
|
||||
|
@ -189,33 +245,6 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating sample campaign: %v", err)
|
||||
}
|
||||
|
||||
// Sample tx template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||
}
|
||||
|
||||
lo.Printf("setup complete")
|
||||
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
|
||||
}
|
||||
|
||||
// installSchema executes the SQL schema and creates the necessary tables and types.
|
||||
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
q, err := fs.Read("/schema.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(q)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the current migration version.
|
||||
return recordMigrationVersion(curVer, db)
|
||||
}
|
||||
|
||||
// recordMigrationVersion inserts the given version (of DB migration) into the
|
||||
|
|
12
cmd/roles.go
12
cmd/roles.go
|
@ -69,7 +69,7 @@ func handleUpdateRole(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Validate.
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Name.String = strings.TrimSpace(r.Name.String)
|
||||
|
||||
out, err := app.core.UpdateRole(id, r)
|
||||
if err != nil {
|
||||
|
@ -99,7 +99,7 @@ func handleDeleteRole(c echo.Context) error {
|
|||
|
||||
func validateRole(r models.Role, app *App) error {
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name, 3, stdInputMaxLen) {
|
||||
if !strHasLen(r.Name.String, 2, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
|
||||
}
|
||||
|
||||
|
@ -109,5 +109,13 @@ func validateRole(r models.Role, app *App) error {
|
|||
}
|
||||
}
|
||||
|
||||
for _, l := range r.Lists {
|
||||
for _, p := range l.Permissions {
|
||||
if p != "list:get" && p != "list:manage" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "list permissions"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@ export default Vue.extend({
|
|||
mounted() {
|
||||
// Lists is required across different views. On app load, fetch the lists
|
||||
// and have them in the store.
|
||||
if (this.$can('lists:get')) {
|
||||
if (this.$can('lists:get_all')) {
|
||||
this.$api.getLists({ minimal: true, per_page: 'all' });
|
||||
}
|
||||
|
||||
|
|
|
@ -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 v-if="$can('lists:get')" :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
|
||||
<b-menu-item v-if="$can('lists:get_all')" :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"
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button v-if="$can('lists:manage')" native-type="submit" type="is-primary" :loading="loading.lists"
|
||||
<b-button v-if="$can('lists:manage_all')" native-type="submit" type="is-primary" :loading="loading.lists"
|
||||
data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-field v-if="$can('lists:manage')" expanded>
|
||||
<b-field v-if="$can('lists:manage_all')" 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>
|
||||
|
|
|
@ -18,23 +18,86 @@
|
|||
<b-input :disabled="disabled" :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
|
||||
</b-field>
|
||||
|
||||
<p class="has-text-right" v-if="!disabled">
|
||||
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
|
||||
</p>
|
||||
<div v-if="!disabled" class="box">
|
||||
<h5>{{ $t('users.listPerms') }}</h5>
|
||||
<div class="mb-5">
|
||||
<div class="columns">
|
||||
<div class="column is-9">
|
||||
<b-select :placeholder="$tc('globals.terms.list')" v-model="form.curList" name="list"
|
||||
:disabled="disabled" expanded class="mb-3">
|
||||
<template v-for="l in filteredLists">
|
||||
<option :value="l.id" :key="l.id">
|
||||
{{ l.name }}
|
||||
</option>
|
||||
</template>
|
||||
</b-select>
|
||||
|
||||
<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]" :disabled="disabled">
|
||||
{{ p }}
|
||||
</b-checkbox>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>{{
|
||||
$t('globals.buttons.add')
|
||||
}}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
<span
|
||||
v-if="form.lists.length > 0 && (form.permissions['lists:get_all'] || form.permissions['lists:manage_all'])"
|
||||
class="is-size-6 has-text-danger">
|
||||
<b-icon icon="warning-empty" />
|
||||
{{ $t('users.listPermsWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<b-table :data="form.lists">
|
||||
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list')">
|
||||
<router-link :to="`/lists/${props.row.id}`" target="_blank">
|
||||
{{ props.row.name }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="permissions" :label="$t('users.perms')" width="40%">
|
||||
<b-checkbox v-model="props.row.permissions" native-value="list:get">
|
||||
{{ $t('globals.buttons.view') }}
|
||||
</b-checkbox>
|
||||
<b-checkbox v-model="props.row.permissions" native-value="list:manage">
|
||||
{{ $t('globals.buttons.manage') }}
|
||||
</b-checkbox>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" width="10%">
|
||||
<a href="#" @click.prevent="onDeleteListPerm(props.row.id)" 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>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<h5 class="mb-0">{{ $t('users.perms') }}</h5>
|
||||
</div>
|
||||
<div class="column has-text-right" v-if="!disabled">
|
||||
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-table :data="serverConfig.permissions">
|
||||
<b-table-column v-slot="props" field="group" :label="$t('users.roleGroup')">
|
||||
{{ $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" :native-value="p" :disabled="disabled">
|
||||
{{ p }}
|
||||
</b-checkbox>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</div>
|
||||
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
|
||||
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
|
||||
</a>
|
||||
|
@ -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();
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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 }));
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
"group": "lists",
|
||||
"permissions":
|
||||
[
|
||||
"lists:get",
|
||||
"lists:manage"
|
||||
"lists:get_all",
|
||||
"lists:manage_all"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
30
queries.sql
30
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;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue