Refactor 'super' user type to a pre-defined super admin role.

This commit is contained in:
Kailash Nadh 2024-06-16 13:50:04 +05:30
parent 8126eec358
commit 32d5823dfe
16 changed files with 183 additions and 91 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:"-"`
}

View file

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

View file

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