Add support for "list roles".

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.
This commit is contained in:
Kailash Nadh 2024-09-02 17:43:56 +05:30
parent 12a6451ed0
commit ae2a386193
26 changed files with 625 additions and 246 deletions

View file

@ -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 {

View file

@ -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.`)

View file

@ -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)
}
@ -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)
}

View file

@ -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)
)
@ -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{}
@ -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
}
@ -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"))
@ -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
}
@ -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"))
}
@ -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" {

View file

@ -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
}

View file

@ -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
}

View file

@ -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',
data,
{ loading: models.roles },
export const getListRoles = async () => http.get(
'/api/roles/lists',
{ loading: models.listRoles, store: models.listRoles },
);
export const updateRole = (data) => http.put(
`/api/roles/${data.id}`,
export const createUserRole = (data) => http.post(
'/api/roles/users',
data,
{ loading: models.roles },
{ 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.userRoles },
);
export const updateListRole = (data) => http.put(
`/api/roles/lists/${data.id}`,
data,
{ loading: models.userRoles },
);
export const deleteRole = (id) => http.delete(
`/api/roles/${id}`,
{ loading: models.roles },
{ loading: models.userRoles },
);

View file

@ -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"

View file

@ -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',

View file

@ -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;
}
@ -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);
});
};

View file

@ -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'),
},
{

View file

@ -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],

View file

@ -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');
},
},

View file

@ -9,7 +9,7 @@
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('users.newRole') }}
{{ type === 'user' ? $t('users.newUserRole') : $t('users.newListRole') }}
</h4>
</header>
@ -19,13 +19,13 @@
required />
</b-field>
<div v-if="!disabled" class="box">
<div v-if="type === 'list'" 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">
:disabled="disabled || filteredLists.length < 1" expanded class="mb-3">
<template v-for="l in filteredLists">
<option :value="l.id" :key="l.id">
{{ l.name }}
@ -74,7 +74,7 @@
</b-table>
</div>
<div class="box">
<template v-if="type === 'user'">
<div class="columns">
<div class="column is-7">
<h5 class="mb-0">
@ -99,7 +99,7 @@
</div>
</b-table-column>
</b-table>
</div>
</template>
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
</a>
@ -132,6 +132,7 @@ export default Vue.extend({
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
type: { type: String, default: 'user' },
},
data() {
@ -186,13 +187,21 @@ export default Vue.extend({
},
createRole() {
const lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
let fn;
const form = { name: this.form.name };
const form = { name: this.form.name, permissions: this.form.permissions, lists };
this.$api.createRole(form).then((data) => {
if (this.$props.type === 'user') {
fn = this.$api.createUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.createListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
this.$parent.close();
@ -200,18 +209,24 @@ export default Vue.extend({
},
updateRole() {
const lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
let fn;
const form = { id: this.$props.data.id, name: this.form.name };
const form = {
id: this.$props.data.id, name: this.form.name, permissions: this.form.permissions, lists,
};
this.$api.updateRole(form).then((data) => {
if (this.$props.type === 'user') {
fn = this.$api.updateUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.updateListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
this.$parent.close();
});
},
},
@ -221,7 +236,7 @@ export default Vue.extend({
// Return the list of unselected lists.
filteredLists() {
if (!this.lists.results) {
if (!this.lists.results || this.type !== 'list') {
return [];
}

View file

@ -3,22 +3,22 @@
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('users.roles') }}
{{ $t(isUser ? 'users.userRoles' : 'users.listRoles') }}
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('users:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm('user')"
data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="roles" :loading="loading.roles" hoverable>
<b-table :data="roles" :loading="isLoading()" hoverable>
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
<a href="#" @click.prevent="showEditForm(props.row)">
<a href="#" @click.prevent="showEditForm(props.row, 'user')">
<b-tag v-if="props.row.id === 1" class="enabled">
{{ props.row.name }}
</b-tag>
@ -36,7 +36,7 @@
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<b-table-column v-slot="props" cell-class="actions has-text-right">
<template v-if="$can('roles:manage')">
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
@ -50,7 +50,7 @@
</a>
<template v-if="props.row.id !== 1">
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
<a href="#" @click.prevent="showEditForm(props.row, 'user')" 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" />
@ -67,14 +67,14 @@
</template>
</b-table-column>
<template #empty v-if="!loading.users">
<template #empty v-if="!isLoading()">
<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" />
<role-form :data="curItem" :type="curType" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
@ -94,28 +94,41 @@ export default Vue.extend({
data() {
return {
curItem: null,
curType: null,
isEditing: false,
isFormVisible: false,
};
},
methods: {
isLoading() {
return this.curType === 'user' ? this.loading.userRoles : this.loading.listRoles;
},
fetchRoles() {
if (this.isUser) {
this.$api.getUserRoles();
} else {
this.$api.getListRoles();
}
},
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.curType = this.isUser ? 'user' : 'list';
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
this.isFormVisible = true;
},
formFinished() {
this.$api.getRoles();
this.fetchRoles();
},
onFormClose() {
@ -125,8 +138,18 @@ export default Vue.extend({
},
onCloneRole(name, item) {
this.$api.createRole({ name, permissions: item.permissions, lists: item.lists }).then(() => {
this.$api.getRoles();
const form = { name };
let fn;
if (this.isUser) {
fn = this.$api.createUserRole;
form.permissions = item.permissions;
} else {
fn = this.$api.createListRole;
form.lists = item.lists;
}
fn(form).then(() => {
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.created', { name }));
});
},
@ -136,7 +159,7 @@ export default Vue.extend({
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteRole(item.id).then(() => {
this.$api.getRoles();
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
@ -147,11 +170,24 @@ export default Vue.extend({
},
computed: {
...mapState(['loading', 'roles']),
...mapState(['loading', 'userRoles', 'listRoles']),
isUser() {
return this.curType === 'user';
},
isList() {
return this.curType === 'list';
},
roles() {
return this.isUser ? this.userRoles : this.listRoles;
},
},
mounted() {
this.$api.getRoles();
this.curType = this.$route.name === 'userRoles' ? 'user' : 'list';
this.fetchRoles();
},
});
</script>

View file

@ -13,11 +13,6 @@
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" ref="focus" autofocus
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
pattern="[a-zA-Z0-9_\-\.]+$" />
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.type')" label-position="on-border">
@ -45,12 +40,10 @@
</div>
</div>
<b-field :label="$tc('users.role')" label-position="on-border">
<b-select v-model="form.roleId" name="role" required expanded>
<option v-for="r in roles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" ref="focus" autofocus
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
pattern="[a-zA-Z0-9_\-\.]+$" />
</b-field>
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
@ -62,28 +55,57 @@
</b-field>
<template v-if="form.type !== 'api'">
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<div class="box">
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
</div>
</div>
</div>
</template>
<h5>{{ $tc('users.roles') }}</h5>
<div class="box">
<div class="columns">
<div class="column is-6">
<b-field :label="$tc('users.userRole')" label-position="on-border">
<b-select v-model="form.userRoleId" name="role" required expanded>
<option v-for="r in userRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$tc('users.listRole', 0)" label-position="on-border">
<b-select v-model="form.listRoleId" name="role" expanded>
<option value="">&mdash; {{ $t("globals.terms.none") }} &mdash;</option>
<option v-for="r in listRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
</div>
</div>
<div v-if="apiToken" class="user-api-token">
<p>{{ $t('users.apiOneTimeToken') }}</p>
<copy-text :text="apiToken" />
@ -161,7 +183,9 @@ export default Vue.extend({
},
createUser() {
const form = { ...this.form, password_login: this.form.passwordLogin, role_id: this.form.roleId };
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.createUser(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@ -178,7 +202,9 @@ export default Vue.extend({
},
updateUser() {
const form = { ...this.form, password_login: this.form.passwordLogin, role_id: this.form.roleId };
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.updateUser({ id: this.data.id, ...form }).then((data) => {
this.$emit('finished');
this.$parent.close();
@ -194,16 +220,19 @@ export default Vue.extend({
},
computed: {
...mapState(['loading', 'roles']),
...mapState(['loading', 'userRoles', 'listRoles']),
},
mounted() {
this.form = { ...this.form, ...this.$props.data };
if (this.$props.data.role) {
this.form.roleId = this.$props.data.role.id;
if (this.$props.data.userRole) {
this.form.userRoleId = this.$props.data.userRole.id;
}
this.$api.getRoles();
this.form.listRoleId = this.$props.data.listRole ? this.$props.data.listRole.id : '';
this.$api.getUserRoles();
this.$api.getListRoles();
this.$nextTick(() => {
this.$refs.focus.focus();

View file

@ -47,18 +47,27 @@
</b-tag>
<div class="has-text-grey is-size-7">
{{ props.row.name }}
<b-tag v-if="props.row.type === 'api'" class="is-small api">
<b-icon icon="code" />
{{ $t(`users.type.${props.row.type}`) }}
</b-tag>
</div>
</b-table-column>
<b-table-column v-slot="props" field="status" :label="$tc('users.role')" header-class="cy-status" sortable
:td-attrs="$utils.tdID">
<b-tag :class="props.row.role.id === 1 ? 'enabled' : ''">
{{ props.row.role.name }}
</b-tag>
<b-tag v-if="props.row.type === 'api'" class="primary">
<b-icon icon="code" />
{{ $t(`users.type.${props.row.type}`) }}
</b-tag>
<router-link :to="{ name: 'userRoles' }">
<b-tag :class="props.row.userRole.id === 1 ? 'enabled' : 'primary'">
<b-icon icon="account-outline" />
{{ props.row.userRole.name }}
</b-tag>
</router-link>
<router-link :to="{ name: 'listRoles' }">
<b-tag v-if="props.row.listRole">
<b-icon icon="newspaper-variant-outline" />
{{ props.row.listRole.name }}
</b-tag>
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable

View file

@ -602,7 +602,12 @@
"users.login": "Login",
"users.role": "Role | Roles",
"users.roles": "Roles",
"users.newRole": "New role",
"users.userRole": "User role | User roles",
"users.userRoles": "User roles",
"users.listRole": "List roles | List role",
"users.listRoles": "List roles",
"users.newUserRole": "New user role",
"users.newListRole": "New list role",
"users.listPerms": "List permissions",
"users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions",
"users.perms": "Permissions",

View file

@ -267,7 +267,7 @@ func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
}
// If the current user is a Super Admin user, do no checks.
if u.Role.ID == SuperAdminRoleID {
if u.UserRole.ID == SuperAdminRoleID {
return next(c)
}

View file

@ -12,9 +12,20 @@ import (
// GetRoles retrieves all roles.
func (c *Core) GetRoles() ([]models.Role, error) {
out := []models.Role{}
if err := c.q.GetRoles.Select(&out); err != nil {
if err := c.q.GetUserRoles.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err)))
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
return out, nil
}
// GetListRoles retrieves all list roles.
func (c *Core) GetListRoles() ([]models.ListRole, error) {
out := []models.ListRole{}
if err := c.q.GetListRoles.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
// Unmarshall the nested list permissions, if any.
@ -35,13 +46,25 @@ func (c *Core) GetRoles() ([]models.Role, error) {
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,
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeUser, pq.Array(r.Permissions)); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil
}
// CreateListRole creates a new list role.
func (c *Core) CreateListRole(r models.ListRole) (models.ListRole, error) {
var out models.ListRole
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeList, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
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,
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
@ -89,22 +112,38 @@ func (c *Core) DeleteListPermission(roleID, listID int) error {
return nil
}
// UpdateRole updates a given role.
func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
// UpdateUserRole updates a given role.
func (c *Core) UpdateUserRole(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)))
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.userRole}", "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, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.userRole}"))
}
return out, nil
}
// UpdateListRole updates a given role.
func (c *Core) UpdateListRole(id int, r models.ListRole) (models.ListRole, error) {
var out models.ListRole
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
if out.ID == 0 {
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.listRole}"))
}
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, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
return out, nil

View file

@ -49,7 +49,7 @@ func (c *Core) CreateUser(u models.User) (models.User, error) {
u.Password = null.String{String: tk, Valid: true}
}
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, u.Status); err != nil {
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
@ -66,7 +66,14 @@ func (c *Core) CreateUser(u models.User) (models.User, error) {
// UpdateUser updates a given user.
func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, u.Status)
listRoleID := 0
if u.ListRoleID == nil {
listRoleID = -1
} else {
listRoleID = *u.ListRoleID
}
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, listRoleID, u.Status)
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
@ -149,6 +156,8 @@ func (c *Core) getUsers(id int, username, email string) ([]models.User, error) {
}
for n, u := range out {
u := u
if u.Password.String != "" {
u.HasPassword = true
u.PasswordLogin = true
@ -158,39 +167,42 @@ func (c *Core) getUsers(id int, username, email string) ([]models.User, error) {
u.Email = null.String{}
}
// Unmarshall the raw list perms map.
var listPerms []models.ListPermission
if u.ListsPermsRaw != nil {
if err := json.Unmarshal(u.ListsPermsRaw, &listPerms); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
}
}
u.Role.ID = u.RoleID
u.Role.Name = u.RoleName
u.Role.Permissions = u.RolePerms
u.Role.Lists = listPerms
u.RoleID = 0
u.UserRole.ID = u.UserRoleID
u.UserRole.Name = u.UserRoleName
u.UserRole.Permissions = u.UserRolePerms
u.UserRoleID = 0
// Prepare lookup maps.
u.ListPermissionsMap = make(map[int]map[string]struct{})
u.PermissionsMap = make(map[string]struct{})
for _, p := range u.RolePerms {
for _, p := range u.UserRolePerms {
u.PermissionsMap[p] = struct{}{}
}
u.ListPermissionsMap = make(map[int]map[string]struct{})
for _, p := range listPerms {
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
for _, perm := range p.Permissions {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == "list:get" {
u.GetListIDs = append(u.GetListIDs, p.ID)
if u.ListRoleID != nil {
// Unmarshall the raw list perms map.
var listPerms []models.ListPermission
if u.ListsPermsRaw != nil {
if err := json.Unmarshal(*u.ListsPermsRaw, &listPerms); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
}
if perm == "list:manage" {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
u.ListRole = &models.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
for _, p := range listPerms {
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
for _, perm := range p.Permissions {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == "list:get" {
u.GetListIDs = append(u.GetListIDs, p.ID)
}
if perm == "list:manage" {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
}
}
}

View file

@ -2,10 +2,13 @@ package migrations
import (
"encoding/json"
"fmt"
"log"
"os"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
@ -18,14 +21,31 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
CREATE TYPE user_type AS ENUM ('user', 'super', 'api');
CREATE TYPE user_type AS ENUM ('user', 'api');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM ('user', 'list');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES 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 roles_idx ON roles (parent_id, list_id);
CREATE UNIQUE INDEX IF NOT EXISTS roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@ -33,28 +53,20 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_roles (
id SERIAL PRIMARY KEY,
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 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,
data jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
`); err != nil {
@ -87,24 +99,53 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
perms = append(perms, p)
}
}
if _, err := db.Exec(`INSERT INTO roles (id, name, permissions) VALUES(1, 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
return err
}
// Create super admin.
var (
user = ko.String("app.admin_username")
password = ko.String("app.admin_password")
user = os.Getenv("LISTMONK_ADMIN_USER")
password = os.Getenv("LISTMONK_ADMIN_PASSWORD")
typ = "env"
)
if len(user) < 2 || len(password) < 8 {
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars in the config file")
if user != "" {
// If the env vars are set, use those values
if len(user) < 2 || len(password) < 8 {
lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars")
}
} else if ko.Exists("app.admin_username") {
// Legacy admin/password are set in the config or env var. Use those.
user = ko.String("app.admin_username")
password = ko.String("app.admin_password")
if len(user) < 2 || len(password) < 8 {
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars")
}
typ = "legacy config"
} else {
// None are set. Auto-generate.
user = "admin"
if p, err := utils.GenerateRandomString(12); err != nil {
lo.Fatal("error generating admin password")
} else {
password = p
}
typ = "auto-generated"
}
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
if _, err := db.Exec(`
INSERT INTO users (username, password_login, password, email, name, type, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'super', 'enabled') ON CONFLICT DO NOTHING;
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
`, user, password, user+"@listmonk", user); err != nil {
return err
}
if typ == "auto-generated" {
fmt.Printf("\n\033[31mIMPORTANT! CHANGE PASSWORD AFTER LOGGING IN\033[0m\nusername: \033[32m%s\033[0m and password: \033[32m%s\033[0m\n\n", user, password)
}
return nil
}

View file

@ -61,6 +61,10 @@ const (
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
// Role.
RoleTypeUser = "user"
RoleTypeList = "list"
// BaseTpl is the name of the base template.
BaseTpl = "base"
@ -160,18 +164,28 @@ type User struct {
Avatar null.String `db:"avatar" json:"avatar"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
// Filled post-retrieval.
Role struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
Lists []ListPermission `db:"-" json:"lists"`
} `db:"-" json:"role"`
// Role struct {
// ID int `db:"-" json:"id"`
// Name string `db:"-" json:"name"`
// Permissions []string `db:"-" json:"permissions"`
// Lists []ListPermission `db:"-" json:"lists"`
// } `db:"-" json:"role"`
RoleID int `db:"role_id" json:"role_id,omitempty"`
RoleName string `db:"role_name" json:"-"`
RolePerms pq.StringArray `db:"role_permissions" json:"-"`
ListsPermsRaw json.RawMessage `db:"list_permissions" json:"-"`
// Filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
PermissionsMap map[string]struct{} `db:"-" json:"-"`
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
@ -186,9 +200,16 @@ type ListPermission struct {
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
@ -198,6 +219,17 @@ type Role struct {
Lists []ListPermission `db:"-" json:"lists"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
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.
type Subscriber struct {
Base
@ -309,6 +341,7 @@ type Campaign struct {
// List of media (attachment) IDs obtained from the next-campaign query
// while sending a campaign.
MediaIDs pq.Int64Array `json:"-" db:"media_id"`
// Fetched bodies of the attachments.
Attachments []Attachment `json:"-" db:"-"`

View file

@ -18,7 +18,7 @@ type Queries struct {
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
UpsertBlocklistSubscriber *sqlx.Stmt `query:"upsert-blocklist-subscriber"`
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
HasSubscriberLists *sqlx.Stmt `query:"has-subscriber-list"`
HasSubscriberLists *sqlx.Stmt `query:"has-subscriber-list"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"`
@ -119,7 +119,8 @@ type Queries struct {
LoginUser *sqlx.Stmt `query:"login-user"`
CreateRole *sqlx.Stmt `query:"create-role"`
GetRoles *sqlx.Stmt `query:"get-roles"`
GetUserRoles *sqlx.Stmt `query:"get-user-roles"`
GetListRoles *sqlx.Stmt `query:"get-list-roles"`
UpdateRole *sqlx.Stmt `query:"update-role"`
DeleteRole *sqlx.Stmt `query:"delete-role"`
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`

View file

@ -1045,7 +1045,7 @@ SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),
'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info;
-- name: create-user
INSERT INTO users (username, password_login, password, email, name, type, role_id, status)
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status)
VALUES($1, $2, (
CASE
-- For user types with password_login enabled, bcrypt and store the hash of the password.
@ -1056,7 +1056,7 @@ INSERT INTO users (username, password_login, password, email, name, type, role_i
THEN $3
ELSE NULL
END
), $4, $5, $6, $7, $8) RETURNING id;
), $4, $5, $6, (SELECT id FROM roles WHERE id = $7 AND type = 'user'), (SELECT id FROM roles WHERE id = $8 AND type = 'list'), $9) RETURNING id;
-- name: update-user
WITH u AS (
@ -1064,7 +1064,7 @@ WITH u AS (
-- if the only superadmin user's status/role isn't being changed.
SELECT
CASE
WHEN (SELECT COUNT(*) FROM users WHERE id != $1 AND status = 'enabled' AND type = 'user' AND role_id = 1) = 0 AND ($8 != 1 OR $9 != 'enabled')
WHEN (SELECT COUNT(*) FROM users WHERE id != $1 AND status = 'enabled' AND type = 'user' AND user_role_id = 1) = 0 AND ($8 != 1 OR $10 != 'enabled')
THEN FALSE
ELSE TRUE
END AS canEdit
@ -1076,19 +1076,58 @@ UPDATE users SET
email=(CASE WHEN $5 != '' THEN $5 ELSE email END),
name=(CASE WHEN $6 != '' THEN $6 ELSE name END),
type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END),
role_id=(CASE WHEN $8 != 0 THEN $8 ELSE role_id END),
status=(CASE WHEN $9 != '' THEN $9::user_status ELSE status END)
user_role_id=(CASE WHEN $8 != 0 THEN (SELECT id FROM roles WHERE id = $8 AND type = 'user') ELSE user_role_id END),
list_role_id=(
CASE
WHEN $9 < 0 THEN NULL
WHEN $9 > 0 THEN (SELECT id FROM roles WHERE id = $9 AND type = 'list')
ELSE list_role_id END
),
status=(CASE WHEN $10 != '' THEN $10::user_status ELSE status END)
WHERE id=$1 AND (SELECT canEdit FROM u) = TRUE;
-- name: delete-users
WITH u AS (
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND role_id=1 AND status='enabled'
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND user_role_id=1 AND status='enabled'
)
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
-- name: get-users
WITH u AS (
SELECT * FROM users
WITH ur AS (
SELECT id, name, permissions FROM roles WHERE type = 'user' AND parent_id IS NULL
),
lr AS (
SELECT r.id, r.name, r.permissions, r.list_id, l.name AS list_name
FROM roles r
LEFT JOIN lists l ON r.list_id = l.id
WHERE r.type = 'list' AND r.parent_id IS NULL
),
lp AS (
SELECT lr.id AS list_role_id,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', COALESCE(cr.list_id, lr.list_id),
'name', COALESCE(cl.name, lr.list_name),
'permissions', COALESCE(cr.permissions, lr.permissions)
)
) AS list_role_perms
FROM lr
LEFT JOIN roles cr ON cr.parent_id = lr.id AND cr.type = 'list'
LEFT JOIN lists cl ON cr.list_id = cl.id
GROUP BY lr.id
)
SELECT
users.*,
ur.id AS user_role_id,
ur.name AS user_role_name,
ur.permissions AS user_role_permissions,
lp.list_role_id,
lr.name AS list_role_name,
lp.list_role_perms
FROM users
LEFT JOIN ur ON users.user_role_id = ur.id
LEFT JOIN lp ON users.list_role_id = lp.list_role_id
LEFT JOIN lr ON lp.list_role_id = lr.id
WHERE
(
CASE
@ -1097,23 +1136,7 @@ WITH u AS (
WHEN $3::TEXT != '' THEN email = $3
ELSE TRUE
END
)
),
role AS (
SELECT id, name, permissions FROM user_roles WHERE id IN (SELECT role_id FROM users)
),
listPerms 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
),
roleInfo AS (
SELECT role.id AS role_id, role.name AS role_name, role.permissions AS role_permissions, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions"
FROM role
LEFT JOIN listPerms l ON role.id = l.parent_id
)
SELECT u.*, ri.* FROM u JOIN roleInfo ri ON u.role_id = ri.role_id;
);
-- name: get-api-tokens
@ -1122,7 +1145,7 @@ SELECT username, password FROM users WHERE status='enabled' AND type='api';
-- name: login-user
WITH u AS (
SELECT users.*, r.name as role_name, r.permissions FROM users
LEFT JOIN user_roles r ON (r.id = users.role_id)
LEFT JOIN roles r ON (r.id = users.user_role_id)
WHERE username=$1 AND status != 'disabled' AND password_login = TRUE
)
SELECT * FROM u WHERE CRYPT($2, password) = password;
@ -1135,40 +1158,54 @@ UPDATE users SET name=$2, email=(CASE WHEN password_login THEN $3 ELSE email END
-- name: update-user-login
UPDATE users SET loggedin_at=NOW(), avatar=(CASE WHEN $2 != '' THEN $2 ELSE avatar END) WHERE id=$1;
-- name: get-roles
-- name: get-user-roles
WITH mainroles AS (
SELECT ur.* FROM user_roles ur WHERE ur.parent_id IS NULL
SELECT ur.* FROM roles ur WHERE type = 'user' AND ur.parent_id IS NULL
),
listPerms 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
FROM 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 listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
-- name: get-list-roles
WITH mainroles AS (
SELECT ur.* FROM roles ur WHERE type = 'list' AND ur.parent_id IS NULL
),
listPerms AS (
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
FROM 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 listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
-- name: create-role
INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
INSERT INTO roles (name, type, permissions, created_at, updated_at) VALUES($1, $2, $3, 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[])
DELETE FROM 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
INSERT INTO roles (parent_id, list_id, permissions, type)
SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), ''), 'list' 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;
DELETE FROM roles WHERE parent_id=$1 AND list_id=$2;
-- name: update-role
UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *;
UPDATE 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;
DELETE FROM roles WHERE id=$1;

View file

@ -9,6 +9,7 @@ DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft'
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api');
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
DROP TYPE IF EXISTS role_type CASCADE; CREATE TYPE role_type AS ENUM ('user', 'list');
CREATE EXTENSION IF NOT EXISTS pgcrypto;
@ -301,18 +302,19 @@ DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON boun
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces((TIMEZONE('UTC', created_at)::DATE));
-- roles
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (
DROP TABLE IF EXISTS roles CASCADE;
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES 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 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;
CREATE UNIQUE INDEX roles_idx ON roles (parent_id, list_id);
CREATE UNIQUE INDEX roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
-- users
DROP TABLE IF EXISTS users CASCADE;
@ -325,7 +327,8 @@ CREATE TABLE users (
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
role_id INTEGER NOT NULL REFERENCES user_roles(id) ON DELETE RESTRICT,
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@ -336,8 +339,8 @@ CREATE TABLE users (
DROP TABLE IF EXISTS sessions CASCADE;
CREATE TABLE sessions (
id TEXT NOT NULL PRIMARY KEY,
data jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
DROP INDEX IF EXISTS idx_sessions; CREATE INDEX idx_sessions ON sessions (id, created_at);