mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
Refactor 'super' user type to a pre-defined super admin role.
This commit is contained in:
parent
8126eec358
commit
32d5823dfe
16 changed files with 183 additions and 91 deletions
2
Makefile
2
Makefile
|
@ -38,7 +38,7 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
|
|||
touch -c $(FRONTEND_YARN_MODULES)
|
||||
|
||||
# Build the backend to ./listmonk.
|
||||
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum
|
||||
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
|
||||
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
|
||||
|
||||
# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
|
||||
|
|
|
@ -14,9 +14,10 @@ import (
|
|||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// install runs the first time setup of creating and
|
||||
// migrating the database and creating the super user.
|
||||
// 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("")
|
||||
|
@ -62,6 +63,16 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
// Load the queries.
|
||||
q := prepareQueries(qMap, db, ko)
|
||||
|
||||
// Super admin role.
|
||||
perms := []string{}
|
||||
for p := range consts.Permissions {
|
||||
perms = append(perms, p)
|
||||
}
|
||||
|
||||
if _, err := q.CreateRole.Exec("Super Admin", pq.Array(perms)); err != nil {
|
||||
lo.Fatalf("error creating super admin role: %v", err)
|
||||
}
|
||||
|
||||
// Create super admin.
|
||||
var (
|
||||
user = ko.String("app.admin_username")
|
||||
|
@ -70,7 +81,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars")
|
||||
}
|
||||
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "super", "enabled"); err != nil {
|
||||
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil {
|
||||
lo.Fatalf("error creating superadmin user: %v", err)
|
||||
}
|
||||
|
||||
|
|
18
cmd/roles.go
18
cmd/roles.go
|
@ -35,7 +35,7 @@ func handleCreateRole(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := validatePerms(r, app); err != nil {
|
||||
if err := validateRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ func handleUpdateRole(c echo.Context) error {
|
|||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
if id < 2 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
|
@ -64,18 +64,13 @@ func handleUpdateRole(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := validatePerms(r, app); err != nil {
|
||||
if err := validateRole(r, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate.
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateRole(id, r)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -102,7 +97,12 @@ func handleDeleteRole(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validatePerms(r models.Role, app *App) error {
|
||||
func validateRole(r models.Role, app *App) error {
|
||||
// Validate fields.
|
||||
if !strHasLen(r.Name, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
|
||||
}
|
||||
|
||||
for _, p := range r.Permissions {
|
||||
if _, ok := app.constants.Permissions[p]; !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "permission"))
|
||||
|
|
|
@ -98,7 +98,9 @@ func handleCreateUser(c echo.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Password = null.String{}
|
||||
if out.Type != models.UserTypeAPI {
|
||||
out.Password = null.String{}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
|
|
@ -15,10 +15,10 @@
|
|||
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
|
||||
<b-input :disabled="disabled" :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
|
||||
</b-field>
|
||||
|
||||
<p class="has-text-right">
|
||||
<p class="has-text-right" v-if="!disabled">
|
||||
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
|
||||
</p>
|
||||
|
||||
|
@ -29,19 +29,22 @@
|
|||
|
||||
<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]">
|
||||
<b-checkbox v-model="form.permissions[p]" :disabled="disabled">
|
||||
{{ p }}
|
||||
</b-checkbox>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
|
||||
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
|
||||
<b-button v-if="!disabled" native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
@ -74,6 +77,7 @@ export default Vue.extend({
|
|||
permissions: {},
|
||||
},
|
||||
hasToggle: false,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -112,7 +116,9 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
updateRole() {
|
||||
const form = { id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions) };
|
||||
const form = {
|
||||
id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions).filter((key) => this.form.permissions[key] === true),
|
||||
};
|
||||
this.$api.updateRole(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
|
@ -133,6 +139,11 @@ export default Vue.extend({
|
|||
acc[key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// It's the superadmin role. Disable the form.
|
||||
if (this.$props.data.id === 1) {
|
||||
this.disabled = true;
|
||||
}
|
||||
} else {
|
||||
const skip = ['admin', 'users'];
|
||||
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
|
||||
|
|
|
@ -19,7 +19,10 @@
|
|||
<b-table :data="roles" :loading="loading.roles" hoverable>
|
||||
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
|
||||
<a href="#" @click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
<b-tag v-if="props.row.id === 1" class="enabled">
|
||||
{{ props.row.name }}
|
||||
</b-tag>
|
||||
<template v-else>{{ props.row.name }}</template>
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
|
@ -34,7 +37,18 @@
|
|||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" cell-class="actions" align="right">
|
||||
<div>
|
||||
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
|
||||
{
|
||||
placeholder: $t('globals.fields.name'),
|
||||
value: $t('campaigns.copyOf', { name: props.row.name }),
|
||||
},
|
||||
(name) => onCloneRole(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
|
||||
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
|
||||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<template v-if="props.row.id !== 1">
|
||||
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
|
||||
:aria-label="$t('globals.buttons.edit')">
|
||||
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
|
||||
|
@ -42,13 +56,13 @@
|
|||
</b-tooltip>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="deleteRole(props.row)" data-cy="btn-delete"
|
||||
<a href="#" @click.prevent="onDeleteRole(props.row)" 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>
|
||||
</div>
|
||||
</template>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty v-if="!loading.users">
|
||||
|
@ -108,7 +122,13 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
|
||||
deleteRole(item) {
|
||||
onCloneRole(name, item) {
|
||||
this.$api.createRole({ name, permissions: item.permissions }).then(() => {
|
||||
this.$api.getRoles();
|
||||
});
|
||||
},
|
||||
|
||||
onDeleteRole(item) {
|
||||
this.$utils.confirm(
|
||||
this.$t('globals.messages.confirm'),
|
||||
() => {
|
||||
|
|
|
@ -21,14 +21,11 @@
|
|||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.type')" label-position="on-border">
|
||||
<b-select v-model="form.type" name="status" required expanded>
|
||||
<option v-if="hasType('user')" value="user">
|
||||
<b-select v-model="form.type" name="status" required expanded :disabled="isEditing">
|
||||
<option value="user">
|
||||
{{ $t('users.type.user') }}
|
||||
</option>
|
||||
<option v-if="hasType('super')" value="super">
|
||||
{{ $t('users.type.super') }}
|
||||
</option>
|
||||
<option v-if="hasType('api')" value="api">
|
||||
<option value="api">
|
||||
{{ $t('users.type.api') }}
|
||||
</option>
|
||||
</b-select>
|
||||
|
@ -48,6 +45,14 @@
|
|||
</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>
|
||||
|
||||
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required />
|
||||
</b-field>
|
||||
|
@ -155,7 +160,7 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
createUser() {
|
||||
const form = { ...this.form, password_login: this.form.passwordLogin };
|
||||
const form = { ...this.form, password_login: this.form.passwordLogin, role_id: this.form.roleId };
|
||||
this.$api.createUser(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
|
||||
|
@ -166,12 +171,13 @@ export default Vue.extend({
|
|||
return;
|
||||
}
|
||||
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
|
||||
updateUser() {
|
||||
const form = { ...this.form, password_login: this.form.passwordLogin };
|
||||
const form = { ...this.form, password_login: this.form.passwordLogin, role_id: this.form.roleId };
|
||||
this.$api.updateUser({ id: this.data.id, ...form }).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
|
@ -187,12 +193,14 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
...mapState(['loading', 'roles']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.form, ...this.$props.data };
|
||||
|
||||
this.$api.getRoles();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
|
|
|
@ -36,11 +36,6 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<b-table-column v-slot="props" field="type" :label="$t('users.type')" sortable>
|
||||
<b-tag :class="{ [props.row.type]: props.row.status === 'enabled' }">
|
||||
{{ $t(`users.type.${props.row.type}`) }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
|
@ -50,9 +45,16 @@
|
|||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
|
||||
sortable :td-attrs="$utils.tdID">
|
||||
<b-tag :class="{ [props.row.status]: true }">
|
||||
<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.roleId === 1 ? 'enabled' : ''">
|
||||
{{ props.row.roleName }}
|
||||
</b-tag>
|
||||
<b-tag v-if="props.row.type === 'api'" class="primary">
|
||||
<b-icon icon="code" />
|
||||
{{ $t(`users.type.${props.row.type}`) }}
|
||||
</b-tag>
|
||||
<b-tag v-if="props.row.status === 'disabled'">
|
||||
{{ $t(`users.status.${props.row.status}`) }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
|
@ -121,12 +123,6 @@ import { mapState } from 'vuex';
|
|||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
import UserForm from './UserForm.vue';
|
||||
|
||||
const TYPE_ICONS = {
|
||||
user: 'account-outline',
|
||||
super: 'account-check-outline',
|
||||
api: 'link-variant',
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
|
@ -209,8 +205,6 @@ export default Vue.extend({
|
|||
},
|
||||
);
|
||||
},
|
||||
|
||||
getTypeIcon: (typ) => TYPE_ICONS[typ],
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
|
|
@ -621,5 +621,6 @@
|
|||
"users.passwordEnable": "Enable password login",
|
||||
"users.passwordMismatch": "Passwords don't match",
|
||||
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
|
||||
"users.cantDelete": "User(s) couldn't be deleted. There has to be at least one 'super' user."
|
||||
"users.needSuper": "User(s) couldn't updated. There has to be at least one 'super' user.",
|
||||
"users.cantDeleteRole": "Cannot delete role that is in use."
|
||||
}
|
||||
|
|
|
@ -293,6 +293,10 @@ func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, models.
|
|||
|
||||
// Fetch user details from the database.
|
||||
user, err := o.cb.GetUser(userID)
|
||||
if err != nil {
|
||||
o.log.Printf("error fetching session user: %v", err)
|
||||
}
|
||||
|
||||
return sess, user, err
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,9 @@ func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
|
|||
// DeleteRole deletes a given role.
|
||||
func (c *Core) DeleteRole(id int) error {
|
||||
if _, err := c.q.DeleteRole.Exec(id); 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)))
|
||||
}
|
||||
|
|
|
@ -69,7 +69,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(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status); err != nil {
|
||||
if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, u.Status); err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
@ -85,15 +85,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.Status)
|
||||
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, 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)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
|
||||
return models.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
return c.GetUser(id, "", "")
|
||||
|
@ -123,7 +122,7 @@ func (c *Core) DeleteUsers(ids []int) error {
|
|||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if num, err := res.RowsAffected(); err != nil || num == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDelete"))
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// V3_1_0 performs the DB migrations.
|
||||
|
@ -37,14 +39,14 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
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 roles(LOWER(name));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON user_roles(LOWER(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
|
@ -63,7 +65,29 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
return err
|
||||
}
|
||||
|
||||
// Insert superuser.
|
||||
// Insert superuser role.
|
||||
pmRaw, err := fs.Read("/permissions.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading permissions file: %v", err)
|
||||
}
|
||||
permGroups := []struct {
|
||||
Group string `json:"group"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}{}
|
||||
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
|
||||
lo.Fatalf("error loading permissions file: %v", err)
|
||||
}
|
||||
|
||||
perms := []string{}
|
||||
for _, group := range permGroups {
|
||||
for _, p := range group.Permissions {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create super admin.
|
||||
var (
|
||||
user = ko.String("app.admin_username")
|
||||
|
|
|
@ -56,7 +56,6 @@ const (
|
|||
ListOptinDouble = "double"
|
||||
|
||||
// User.
|
||||
UserTypeSuperadmin = "super"
|
||||
UserTypeUser = "user"
|
||||
UserTypeAPI = "api"
|
||||
UserStatusEnabled = "enabled"
|
||||
|
@ -152,15 +151,19 @@ type User struct {
|
|||
Username string `db:"username" json:"username"`
|
||||
|
||||
// For API users, this is the plaintext API token.
|
||||
Password null.String `db:"password" json:"password,omitempty"`
|
||||
PasswordLogin bool `db:"password_login" json:"password_login"`
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Avatar null.String `db:"-" json:"avatar"`
|
||||
Permissions map[string]struct{} `db:"-" json:"-"`
|
||||
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
||||
Password null.String `db:"password" json:"password,omitempty"`
|
||||
PasswordLogin bool `db:"password_login" json:"password_login"`
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
RoleID int `db:"role_id" json:"role_id"`
|
||||
RoleName string `db:"role_name" json:"role_name"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Avatar null.String `db:"-" json:"avatar"`
|
||||
|
||||
PermissionsTbl map[string]struct{} `db:"-" json:"-"`
|
||||
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
||||
|
||||
HasPassword bool `db:"-" json:"-"`
|
||||
}
|
||||
|
|
34
queries.sql
34
queries.sql
|
@ -1028,7 +1028,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, status)
|
||||
INSERT INTO users (username, password_login, password, email, name, type, role_id, status)
|
||||
VALUES($1, $2, (
|
||||
CASE
|
||||
-- For user types with password_login enabled, bcrypt and store the hash of the password.
|
||||
|
@ -1039,9 +1039,12 @@ INSERT INTO users (username, password_login, password, email, name, type, status
|
|||
THEN $3
|
||||
ELSE NULL
|
||||
END
|
||||
), $4, $5, $6, $7) RETURNING *;
|
||||
), $4, $5, $6, $7, $8) RETURNING *;
|
||||
|
||||
-- name: update-user
|
||||
WITH u AS (
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = $1) AND role_id=1 AND status='enabled'
|
||||
)
|
||||
UPDATE users SET
|
||||
username=(CASE WHEN $2 != '' THEN $2 ELSE username END),
|
||||
password_login=$3,
|
||||
|
@ -1049,27 +1052,32 @@ 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),
|
||||
status=(CASE WHEN $8 != '' THEN $8::user_status ELSE status END)
|
||||
WHERE id=$1;
|
||||
role_id=(CASE WHEN $8 != 0 THEN $8 ELSE role_id END),
|
||||
status=(CASE WHEN $9 != '' THEN $9::user_status ELSE status END)
|
||||
WHERE id=$1 AND (SELECT num FROM u) > 0;
|
||||
|
||||
-- name: delete-users
|
||||
WITH u AS (
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND type='super'
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND role_id=1 AND status='enabled'
|
||||
)
|
||||
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
|
||||
|
||||
-- name: get-user
|
||||
SELECT * FROM users WHERE
|
||||
SELECT users.*, r.name as role_name, r.permissions FROM users
|
||||
LEFT JOIN user_roles r ON (r.id = users.role_id)
|
||||
WHERE
|
||||
(
|
||||
CASE
|
||||
WHEN $1::INT != 0 THEN id = $1
|
||||
WHEN $1::INT != 0 THEN users.id = $1
|
||||
WHEN $2::TEXT != '' THEN username = $2
|
||||
WHEN $3::TEXT != '' THEN email = $3
|
||||
END
|
||||
) AND status='enabled';
|
||||
);
|
||||
|
||||
-- name: get-users
|
||||
SELECT * FROM users WHERE $1=0 OR id=$1 ORDER BY created_at;
|
||||
SELECT users.*, r.name as role_name, r.permissions FROM users
|
||||
LEFT JOIN user_roles r ON (r.id = users.role_id)
|
||||
WHERE $1=0 OR users.id=$1 ORDER BY created_at;
|
||||
|
||||
-- name: get-api-tokens
|
||||
SELECT username, password FROM users WHERE status='enabled' AND type='api';
|
||||
|
@ -1086,13 +1094,13 @@ UPDATE users SET name=$2, email=$3,
|
|||
WHERE id=$1;
|
||||
|
||||
-- name: get-roles
|
||||
SELECT * FROM roles ORDER BY created_at;
|
||||
SELECT * FROM user_roles ORDER BY created_at;
|
||||
|
||||
-- name: create-role
|
||||
INSERT INTO roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
|
||||
INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
|
||||
|
||||
-- name: update-role
|
||||
UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *;
|
||||
UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *;
|
||||
|
||||
-- name: delete-role
|
||||
DELETE FROM roles WHERE id=$1;
|
||||
DELETE FROM user_roles WHERE id=$1;
|
||||
|
|
24
schema.sql
24
schema.sql
|
@ -7,7 +7,7 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r
|
|||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
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', 'super', 'api');
|
||||
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');
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
@ -300,6 +300,17 @@ DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bo
|
|||
DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
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));
|
||||
|
||||
-- users
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
CREATE TABLE users (
|
||||
|
@ -310,21 +321,14 @@ CREATE TABLE users (
|
|||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type user_type NOT NULL DEFAULT 'user',
|
||||
role_id INTEGER NOT NULL REFERENCES user_roles(id) ON DELETE RESTRICT,
|
||||
status user_status NOT NULL DEFAULT 'disabled',
|
||||
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS roles CASCADE;
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
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
|
||||
);
|
||||
DROP INDEX IF EXISTS idx_roles_name; CREATE UNIQUE INDEX idx_roles_name ON roles(LOWER(name));
|
||||
|
||||
-- user sessions
|
||||
DROP TABLE IF EXISTS sessions CASCADE;
|
||||
|
|
Loading…
Reference in a new issue